commit 28819b6f255e91efa57b8844e49a2e515f2bfdb4 Author: tkgstrator Date: Sat Mar 14 18:29:30 2026 +0000 chore: prepare for commit message generation without code changes diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..d5e5c9c --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM mcr.microsoft.com/devcontainers/python:dev-3.12 diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml new file mode 100644 index 0000000..3b84ac6 --- /dev/null +++ b/.devcontainer/compose.yaml @@ -0,0 +1,14 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + volumes: + - ../:/home/vscode/app:cached + - venv:/home/vscode/app/.venv + tty: true + stdin_open: true + +volumes: + venv: + driver: local diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..57511cd --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,53 @@ +{ + "name": "Frida Container", + "dockerComposeFile": [ + "compose.yaml" + ], + "service": "app", + "workspaceFolder": "/home/vscode/app", + "shutdownAction": "stopCompose", + "remoteUser": "vscode", + "mounts": [ + "source=${env:HOME}/home/vscode/.ssh,target=/.ssh,type=bind,consistency=cached,readonly" + ], + "remoteEnv": { + "WAKATIME_API_KEY": "${localEnv:WAKATIME_API_KEY}", + "UV_LINK_MODE": "copy" + }, + "features": { + "ghcr.io/devcontainers/features/git:1": { + "version": "2.52.0" + }, + "ghcr.io/devcontainers/features/common-utils:2": { + "configureZshAsDefaultShell": true + }, + "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {}, + "ghcr.io/christophermacgown/devcontainer-features/direnv:1": {} + }, + "postAttachCommand": "/bin/sh .devcontainer/postAttachCommand.sh", + "postCreateCommand": "/bin/sh .devcontainer/postCreateCommand.sh", + "customizations": { + "vscode": { + "extensions": [ + "Anthropic.claude-code", + "EditorConfig.EditorConfig", + "GitHub.copilot", + "GitHub.copilot-chat", + "PKief.material-icon-theme", + "antfu.file-nesting", + "bierner.markdown-mermaid", + "bierner.markdown-preview-github-styles", + "charliermarsh.ruff", + "eamodio.gitlens", + "jebbs.markdown-extended", + "ms-python.black-formatter", + "ms-python.python", + "pHofer94.vscode-taskviewexplorer", + "redhat.vscode-yaml", + "tamasfe.even-better-toml", + "vsls-contrib.gitdoc" + ], + "settings": {} + } + } +} \ No newline at end of file diff --git a/.devcontainer/postAttachCommand.sh b/.devcontainer/postAttachCommand.sh new file mode 100644 index 0000000..18aba14 --- /dev/null +++ b/.devcontainer/postAttachCommand.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +git config --global --unset commit.template +git config --global --add safe.directory /home/vscode/app +git config --global fetch.prune true +git config --global --add --bool push.autoSetupRemote true +git config --global commit.gpgSign true +git config --global user.signingkey $(gpg --list-secret-keys --with-colons | grep -B 3 "uid.*$(git config user.name)" | cut -d: -f5 | sed ':a;N;$!ba;s/\n//g') +git branch --merged|egrep -v '\*|develop|main|master'|xargs git branch -d + +direnv allow /home/vscode/app/.envrc diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh new file mode 100644 index 0000000..200dbfc --- /dev/null +++ b/.devcontainer/postCreateCommand.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +sudo chown -R "$(whoami)":"$(whoami)" /home/"$(whoami)"/app/.venv +uv sync diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..93ff1da --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_size = 4 +indent_style = space +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.yml] +indent_size = 2 + +[*.yaml] +indent_size = 2 diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..7cab2f3 --- /dev/null +++ b/.envrc @@ -0,0 +1,5 @@ +source .venv/bin/activate +dotenv + +alias frida-trace="frida-trace -H $HOST --ui-port $UI_PORT" +alias frida-ps="frida-ps -H $HOST" \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..defc39f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,136 @@ +# プロジェクト概要 + +Netflix iOS/Androidアプリのリバースエンジニアリングプロジェクト。 +対象アプリが送信するHTTPリクエストの安全性を検証するため、暗号化されたリクエストボディの復号に必要な鍵・アルゴリズムを特定する。 + +## 対象アプリ + +- アプリ名: Netflix +- Bundle ID (iOS): `com.netflix.Netflix` +- Bundle ID (Android): `com.netflix.mediaclient` + +## 環境構成 + +| 項目 | バージョン / 値 | +|---|---| +| Python | 3.12(パッケージ管理: uv) | +| Frida | 17.8.0 | +| Objection | 1.12.3 | + +### 対象デバイス + +| デバイス | OS | ホスト | +|---|---|---| +| iPhone (Jailbroken) | iOS 15.8.3 | `192.168.0.34` | +| Pixel 4a (5G) / `bramble` | Android 14 (API 34) | `192.168.0.37` | + +# ドキュメント運用 + +## 保存先 + +ドキュメント作成を指示された場合、以下の2種類を必ず作成すること: + +| 種別 | 保存先 | 形式 | +|---|---|---| +| LLM向け | `.github/instructions/{名前}.instructions.md` | frontmatterに `applyTo` を記述し、関連ファイルパターンを指定。構造化された簡潔な形式 | +| 人間向け | `docs/{名前}.md` | 散文形式。図解にはMermaid記法を使用し、テーマは `%%{init: {'theme': 'dark'}}%%` を指定すること | + +## 制約 + +- 1ファイルが400行を超える場合は、指示がなくてもトピック単位で分割すること +- ファイル名はスネークケースを使用すること + +## 主要ドキュメント一覧 + +LLM向けの詳細コンテキストは以下を参照すること: + +- `.github/instructions/*.instructions.md` — ファイルスコープ付きLLM向け指示(`applyTo` で適用先を指定) +- `docs/` — 人間向けドキュメント + +| パス | 内容 | +|---|---| +| `docs/msl_ios.md` | iOS MSLプロトコルの構造・暗号スタック | +| `docs/msl_android.md` | Android MSLプロトコルの構造 | +| `docs/manifest_ios.md` | iOSマニフェスト仕様 | +| `docs/manifest_android.md` | Androidマニフェスト仕様 | +| `docs/auth_flow_android.md` | Android認証フロー | +| `docs/esn_android.md` | Android ESN仕様 | +| `docs/pxa_esn.md` | PXA ESN仕様 | + +# ツール使用ルール + +## Objection + +> **重要**: 以下の非推奨コマンド・オプションをコード生成やコマンド提案で絶対に使用しないこと。 + +| 禁止 | 代替 | 説明 | +|---|---|---| +| `explore` | `start` | アプリへの接続コマンド | +| `--gadget` / `-g` | `--name` / `-n` | 対象アプリ指定オプション | + +正しい使用例: + +```bash +objection -n "com.netflix.Netflix" start +``` + +## Frida + +- コマンド実行時は必ず `-H <ホストIP>` を指定して対象デバイスに接続すること + - iOS: `-H 192.168.0.34` + - Android: `-H 192.168.0.37` +- 実行中アプリ一覧の確認: `frida-ps -H <ホストIP> -a` + +# アーキテクチャ + +## 目的 + +対象アプリの暗号化通信(MSL等)をFridaでフックし、暗号化前の平文データ・復号済みデータ・暗号鍵・IVなどを取得する。 + +## JS / Python の役割分担 + +| レイヤー | ファイル | 責務 | +|---|---|---| +| JavaScript (Frida) | `hook_*.js` | 対象アプリのプロセス内で動作。暗号関数・API呼び出しをフックし、引数や戻り値(鍵・IV・平文・暗号文など)を `@@LOG@@{json}` 形式で `console.log` に出力する。**データの加工・保存はしない。** | +| Python (ホスト) | `run.py` | Fridaプロセスを起動・管理する。stdout から `@@LOG@@` プレフィクス付きの行をパースし、ドメイン別・イベント別にJSON/バイナリファイルとして `logs/` に保存する。MSL平文とHTTPリクエストの紐付け、Cookie/ヘッダの自動エクスポートも行う。 | + +### ログプロトコル + +JavaScript側は以下の形式で標準出力にログを送る: + +``` +@@LOG@@{"event":"msl.aesCbcEncrypt.key","key_b64":"...","ts":"..."} +``` + +- プレフィクス `@@LOG@@` に続くJSON文字列をPython側がパースする +- `event` フィールドでイベント種別を識別する +- バイナリデータは `_b64`(Base64)または `_hex`(16進数)サフィックスのフィールドで渡す + +### シグナルハンドリング + +`run.py` は `SIGINT` / `SIGTERM` を捕捉し、Fridaサブプロセスを安全に終了させる(`terminate` → 3秒待機 → `kill`)。Ctrl+C での中断時もログの集計とCookie/ヘッダのエクスポートが `finally` ブロックで実行される。フックスクリプト作成時は、JS側での終了処理は不要(Python側が管理する)。 + +## コード構成 + +| パス | 役割 | +|---|---| +| `hook_netflix.js` | iOS用フックスクリプト(ObjC/C++関数のフック) | +| `hook_netflix_android.js` | Android用フックスクリプト(Java/JNIメソッドのフック) | +| `hook_msl.js` | MSL暗号関数の個別フック | +| `hook_cronet.js` | Cronetネットワーク層のフック | +| `hook_esn.js` | ESN取得用フック | +| `hook_headers.js` | HTTPヘッダ/Cookie取得用フック | +| `run.py` | メインランナー(iOS/Android両対応、`--android` フラグで切替) | +| `run_android.sh` | Android用シェルラッパー | +| `run_cronet.py` | Cronet用ランナー | +| `__handlers__/` | Objectionハンドラ | + +## 新しいフックを書くときの方針 + +1. **静的解析で対象関数を特定する**: フックを書く前に、アプリのバイナリを静的解析して目的の関数・メソッドを探すこと。推測でフック対象を決めてはならない + - iOS: `strings`, `nm --demangle`, `class-dump` 等でMach-Oバイナリからシンボル・クラス・セレクタを抽出する + - Android: `jadx` でAPK/DEXを逆コンパイルし、クラス名・メソッドシグネチャを確認する。ProGuardで難読化されている場合は既存フックのコメントにあるマッピングを参照する +2. **JS側**: 対象関数をフックし、取得したデータを `@@LOG@@{json}` で出力するだけにする +3. **Python側**: ログのパース・整形・保存・紐付けロジックは `run.py` に集約する +4. バイナリデータはJS側でBase64または16進数に変換してからログに含める +5. 終了処理はPython側が担うため、JS側で `Script.on('unload')` 等の後処理は基本不要 diff --git a/.github/instructions/auth_flow_android.instructions.md b/.github/instructions/auth_flow_android.instructions.md new file mode 100644 index 0000000..31b199f --- /dev/null +++ b/.github/instructions/auth_flow_android.instructions.md @@ -0,0 +1,44 @@ +--- +applyTo: 'hook_netflix_android.js,hook_msl.js,run_android.sh,docs/auth_flow_android*.md' +--- + +## Android 認証フロー (LLM 向け) + +### ドキュメント構成 + +| ファイル | 内容 | +|---|---| +| `docs/auth_flow_android.md` | フロー概要・Mermaid 図・鍵交換・Cookie フロー・ProxyESN ライフサイクル・セキュリティ観察 | +| `docs/auth_flow_android_api.md` | 全 API のリクエスト/レスポンス詳細 (Header/Cookie/Body 表)・Persisted Query 一覧・完全タイムライン | + +### 認証フロー要約 + +1. **Phase 0:** `ProxyEsn.$init` で PXA ESN の TTL チェック。期限切れなら再取得フラグ ON +2. **Phase 1:** 7 リクエストを並列送信 (appboot, getProxyEsn, aleProvision#1, RenewSSOToken, CurrentCountryQuery, Interstitial×2) +3. **Phase 2:** レスポンス受信。CurrentCountryQuery で `NetflixId`/`SecureNetflixId` Cookie 発行、appboot で `nfvdid` Cookie 更新 +4. **Phase 3:** AccountQuery, aleProvision#2 (→PXA ESN 確定), PromoProfileGateVideoDataQuery +5. **Phase 4:** 約 30 秒後に FetchConfigData, AccountQuery#2 + +### エンドポイント + +| 略称 | ホスト | 用途 | +|---|---|---| +| appboot | `android14.appboot.netflix.com` | デバイス登録 | +| prod.ftl | `android14.prod.ftl.netflix.com` | MSL API + non-MSL GraphQL | +| prod.cloud | `android14.prod.cloud.netflix.com` | MSL GraphQL | + +### 認証方式の二重構造 + +- **MSL API** (`prod.cloud`, `prod.ftl` の MSL エンドポイント): Master Token + User Auth Data で認証。Cookie 不要 +- **Non-MSL GraphQL** (`prod.ftl/graphql`): `NetflixId` / `SecureNetflixId` Cookie で認証 + +### 鍵交換 + +- アルゴリズム: RSA-OAEP-256 (鍵交換) + A128GCM (セッション暗号化) +- aleProvision は 2 回呼ばれる。#1 は起動直後、#2 は getProxyEsn レスポンス後 +- 同一 RSA-2048 公開鍵を使用。サーバーが新セッション鍵を発行 + +### Frida フック固有の注意 + +- `hook_msl.js` が `ProxyEsn.$init` で `expired=true` を強制。通常は TTL が有効ならキャッシュ使用 +- キャプチャ内の PXA ESN 再取得は Frida による強制失効の結果 diff --git a/.github/instructions/message.instructions.md b/.github/instructions/message.instructions.md new file mode 100644 index 0000000..6e95567 --- /dev/null +++ b/.github/instructions/message.instructions.md @@ -0,0 +1,54 @@ +--- +applyTo: 'ipa_extracted/**' +--- + +## MSL 解析手順 (LLM 向け) + +### 静的解析 + +1. IPA を展開: `unzip -o Netflix-15.48.1.ipa -d ipa_extracted/` +2. MSL バイナリ: `ipa_extracted/Payload/Argo.app/Frameworks/MslClient.framework/MslClient` (arm64 Mach-O) +3. ObjC クラス抽出: `strings MslClient | grep -E '^\+\[|^\-\[' | sed 's/\[//;s/ .*//' | sort -u` +4. C++ シンボル抽出: `nm --demangle MslClient | grep 'netflix.*msl'` +5. JSON フィールド名・定数: `strings MslClient | grep -E '^(mastertoken|headerdata|payloadchunk|scheme|...)$'` + +### 動的解析 (Frida) + +`hook_netflix.js` を Frida Gadget 注入済み IPA で実行。`run.py` が `@@LOG@@{json}` をパースし `logs/{date}/{domain}/` に保存。 + +**Hook 関数と目的:** + +| 関数 | Hook 対象 | 取得データ | +|---|---|---| +| `hookSSLPinning()` | `-[* URLSession:didReceiveChallenge:completionHandler:]` (NF/Netflix/Osprey) | SSL pinning 回避 | +| `hookSSL()` (無効) | `SSL_write` / `SSL_read` (libboringssl) | TLS 平文 (MSL 暗号文のまま) | +| `hookMSL()` | `IosMslClient -sendAPIRequest:extraHeaders:params:userAuthData:requestOptions:callback:` | **暗号化前の API パス (args[2]) とパラメータ (args[4])** | +| `hookMSL()` | `IosMdxCryptoContext -encrypt:` / `-decrypt:` | MDX 暗号化前後のデータ | +| `hookObjCTrace()` | `+[NSURL URLWithString:]` / `-[NSMutableURLRequest setHTTPBody:]` | URL と HTTP ボディ | +| `hookCrypto()` (無効) | `CCCrypt` / `SecKeyEncrypt` / `SecKeyRawSign` | CommonCrypto/Security 暗号処理 | +| `hookMslCrypto()` | `aesCbcEncrypt` / `aesCbcDecrypt` (MslClient) | AES-CBC 鍵・IV・平文・暗号文 | +| `hookMslCrypto()` | `signHmacSha256` (MslClient) | HMAC 鍵・署名対象・署名値 | +| `hookMslCrypto()` | `aesKwUnwrap` (MslClient) | KEK・ラップ鍵 → セッション鍵 | +| `hookMslCrypto()` | `dhComputeSharedSecret` (MslClient) | DH 秘密鍵・公開鍵・素数 → 共有鍵 | +| `hookMslCrypto()` | `rsaEncrypt` / `rsaDecrypt` (MslClient) | RSA 入出力 | + +**C++ 引数の読み方:** `std::vector` は `+0x00: __begin_` (ptr), `+0x08: __end_` (ptr)。size = end - begin。 + +**ObjC args オフセット:** args[0]=self, args[1]=_cmd, args[2]~ が実引数。 + +### ログイベント種別 + +| event | 内容 | +|---|---| +| `msl.api` | MSL API リクエスト (domain, url, params) | +| `msl.encrypt.input` / `msl.decrypt.output` | MDX 暗号化/復号データ | +| `msl.aesCbcEncrypt.*` / `msl.aesCbcDecrypt.*` | AES-CBC の key, iv, plaintext, ciphertext | +| `msl.hmacSha256.*` | HMAC の key, data, signature | +| `msl.aesKwUnwrap.*` | AES-KW の kek, wrappedKey, unwrappedKey | +| `msl.dh.*` | DH の privKey, pubKey, prime, sharedSecret | +| `url` | URL アクセス | +| `http.request` | HTTP リクエスト (method, url, content_type, body) | + +### 詳細ドキュメント + +MSL プロトコルの構造・暗号スタック・クラス構成の詳細は `docs/msl.md` を参照。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97ed4fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,282 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,venv,windows,linux,macos +# Edit at https://www.toptal.com/developers/gitignore?templates=python,venv,windows,linux,macos + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### venv ### +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +pip-selfcheck.json + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/python,venv,windows,linux,macos + +# Application binaries +*.apk +*.ipa +*.dylib + +# Extracted / dumped +extracted_ipa/ +extracted_apk/ + +# Logs & captures +logs/ +cookie.txt +headers.md + +# Locks +uv.lock + +# Secrets / env +.env diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bb2ce7a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "gitdoc.enabled": true +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/__handlers__/Foundation.__NSSwiftData/initWithBase64EncodedData_options_.js b/__handlers__/Foundation.__NSSwiftData/initWithBase64EncodedData_options_.js new file mode 100644 index 0000000..325dce8 --- /dev/null +++ b/__handlers__/Foundation.__NSSwiftData/initWithBase64EncodedData_options_.js @@ -0,0 +1,19 @@ +/* + * Auto-generated by Frida. Please modify to match the signature of -[Foundation.__NSSwiftData initWithBase64EncodedData:options:]. + * This stub is currently auto-generated from manpages when available. + * + * For full API reference, see: https://frida.re/docs/javascript-api/ + */ + +defineHandler({ + onEnter(log, args, state) { + this.arg2 = args[2]; + }, + + onLeave(log, retval, state) { + var before = ObjC.classes.NSString.alloc().initWithData_encoding_(this.arg2, 4); + // var after = ObjC.classes.NSString.alloc().initWithData_encoding_(retval, 4); + log(`-[NSData initWithBase64EncodedData:]before=${before}=`); + // log(`-[NSData initWithBase64EncodedData:]after=${after}=`); + } +}); diff --git a/__handlers__/Foundation.__NSSwiftData/initWithBase64EncodedString_options_.js b/__handlers__/Foundation.__NSSwiftData/initWithBase64EncodedString_options_.js new file mode 100644 index 0000000..fc68d71 --- /dev/null +++ b/__handlers__/Foundation.__NSSwiftData/initWithBase64EncodedString_options_.js @@ -0,0 +1,19 @@ +/* + * Auto-generated by Frida. Please modify to match the signature of -[Foundation.__NSSwiftData initWithBase64EncodedString:options:]. + * This stub is currently auto-generated from manpages when available. + * + * For full API reference, see: https://frida.re/docs/javascript-api/ + */ + +defineHandler({ + onEnter(log, args, state) { + this.arg2 = args[2]; + }, + + onLeave(log, retval, state) { + var before = new ObjC.Object(this.arg2); + var after = ObjC.classes.NSString.alloc().initWithData_encoding_(retval, 4); + // log(`-[NSData initWithBase64EncodedString:]before=${before}=`); + log(`-[NSData initWithBase64EncodedString:]after=${after}=`); + } +}); diff --git a/__handlers__/NSData/initWithBase64EncodedData_options_.js b/__handlers__/NSData/initWithBase64EncodedData_options_.js new file mode 100644 index 0000000..eb34eee --- /dev/null +++ b/__handlers__/NSData/initWithBase64EncodedData_options_.js @@ -0,0 +1,19 @@ +/* + * Auto-generated by Frida. Please modify to match the signature of -[NSData initWithBase64EncodedData:options:]. + * This stub is currently auto-generated from manpages when available. + * + * For full API reference, see: https://frida.re/docs/javascript-api/ + */ + +defineHandler({ + onEnter(log, args, state) { + this.arg2 = args[2]; + }, + + onLeave(log, retval, state) { + var before = ObjC.classes.NSString.alloc().initWithData_encoding_(this.arg2, 4); + // var after = ObjC.classes.NSString.alloc().initWithData_encoding_(retval, 4); + log(`-[NSData initWithBase64EncodedData:]before=${before}=`); + // log(`-[NSData initWithBase64EncodedData:]after=${after}=`); + } +}); diff --git a/__handlers__/NSData/initWithBase64EncodedString_options_.js b/__handlers__/NSData/initWithBase64EncodedString_options_.js new file mode 100644 index 0000000..5720730 --- /dev/null +++ b/__handlers__/NSData/initWithBase64EncodedString_options_.js @@ -0,0 +1,19 @@ +/* + * Auto-generated by Frida. Please modify to match the signature of -[NSData initWithBase64EncodedString:options:]. + * This stub is currently auto-generated from manpages when available. + * + * For full API reference, see: https://frida.re/docs/javascript-api/ + */ + +defineHandler({ + onEnter(log, args, state) { + this.arg2 = args[2]; + }, + + onLeave(log, retval, state) { + var before = new ObjC.Object(this.arg2); + var after = ObjC.classes.NSString.alloc().initWithData_encoding_(retval, 4); + // log(`-[NSData initWithBase64EncodedString:]before=${before}=`); + log(`-[NSData initWithBase64EncodedString:]after=${after}=`); + } +}); diff --git a/__handlers__/Security/SecKeyEncrypt.js b/__handlers__/Security/SecKeyEncrypt.js new file mode 100644 index 0000000..8129ca4 --- /dev/null +++ b/__handlers__/Security/SecKeyEncrypt.js @@ -0,0 +1,18 @@ +/* + * Auto-generated by Frida. Please modify to match the signature of SecKeyEncrypt. + * This stub is currently auto-generated from manpages when available. + * + * For full API reference, see: https://frida.re/docs/javascript-api/ + */ + +defineHandler({ + onEnter(log, args, state) { + log(`SecKeyEncrypt()=${args[2].readCString()}=`); + log('SecKeyEncrypt called from:\n' + + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join('\n') + '\n'); + }, + + onLeave(log, retval, state) { + } +}); diff --git a/__handlers__/Security/SecKeyRawSign.js b/__handlers__/Security/SecKeyRawSign.js new file mode 100644 index 0000000..52e8873 --- /dev/null +++ b/__handlers__/Security/SecKeyRawSign.js @@ -0,0 +1,18 @@ +/* + * Auto-generated by Frida. Please modify to match the signature of SecKeyRawSign. + * This stub is currently auto-generated from manpages when available. + * + * For full API reference, see: https://frida.re/docs/javascript-api/ + */ + +defineHandler({ + onEnter(log, args, state) { + log(`SecKeyRawSign()=${args[2].readCString()}=`); + log('SecKeyRawSign called from:\n' + + Thread.backtrace(this.context, Backtracer.ACCURATE) + .map(DebugSymbol.fromAddress).join('\n') + '\n'); + }, + + onLeave(log, retval, state) { + } +}); diff --git a/docs/auth_flow_android.md b/docs/auth_flow_android.md new file mode 100644 index 0000000..d53ff26 --- /dev/null +++ b/docs/auth_flow_android.md @@ -0,0 +1,253 @@ +# Netflix Android 起動時認証フロー + +> **対象:** Netflix Android v9.57.0 (build 63928) +> **デバイス:** Pixel 4a (5G) / bramble / Android 14 +> **取得方法:** Frida フック (`hook_netflix_android.js`) + `run.py` による JSONL キャプチャ +> **キャプチャ日:** 2026-03-14 + +--- + +## 1. 概要 + +Netflix Android アプリの起動時に発生する認証・プロビジョニングフローを、キャプチャしたトラフィックから再構成した。 + +各 API のリクエスト・レスポンス詳細は [auth_flow_android_api.md](auth_flow_android_api.md) を参照。 + +### 1.1 通信先エンドポイント + +| エンドポイント | 略称 | プロトコル | 用途 | +|---|---|---|---| +| `android14.appboot.netflix.com` | **appboot** | HTTPS (MSL レスポンス) | デバイス登録・`nfvdid` Cookie 発行 | +| `android14.prod.ftl.netflix.com` | **prod.ftl** | HTTPS + MSL | MSL API (`/nq/androidui/samurai/`) + GraphQL | +| `android14.prod.cloud.netflix.com` | **prod.cloud** | HTTPS + MSL | GraphQL via MSL | + +### 1.2 通信の分類 + +| 分類 | Content-Encoding | ボディ形式 | 認証方式 | +|---|---|---|---| +| **MSL API** | `msl_v1` | CBOR → GZIP → JSON | MSL トークン (Master Token + User Auth Data) | +| **Non-MSL GraphQL** | なし | JSON | Cookie (`NetflixId` / `SecureNetflixId`) | +| **Appboot** | なし (レスポンスは `application/x-msl+json`) | Query String | `nfvdid` Cookie | + +--- + +## 2. 認証フロー全体像 + +```mermaid +%%{init: {'theme':'dark'}}%% +sequenceDiagram + participant App as Netflix App + participant AB as appboot + participant FTL as prod.ftl
(MSL API) + participant Cloud as prod.cloud
(GraphQL) + + Note over App: 起動 + App->>App: ProxyESN 期限チェック (※) + + rect rgba(180, 100, 50, 0.2) + Note over App,Cloud: Phase 1: リクエスト送信 + App->>AB: POST /appboot/{BaseESN} + App->>FTL: [MSL] /getProxyEsn + App->>FTL: [MSL] /aleProvision #1 (RSA鍵交換) + App->>Cloud: [MSL] RenewSSOToken + App->>FTL: CurrentCountryQuery + App->>Cloud: [MSL] InterstitialForProfileGate + App->>Cloud: [MSL] InterstitialForLolomo + end + + rect rgba(50, 180, 80, 0.2) + Note over App,Cloud: Phase 2: レスポンス受信 + FTL-->>App: CurrentCountryQuery → +NetflixId, SecureNetflixId [Cookie] + FTL-->>App: getProxyEsn → MSL レスポンス + FTL-->>App: aleProvision #1 → MSL レスポンス + Cloud-->>App: RenewSSOToken → 200 OK + AB-->>App: appboot → nfvdid [Cookie] + DeviceIdToken + Cloud-->>App: InterstitialForLolomo → 200 OK + Cloud-->>App: InterstitialForProfileGate → 200 OK + end + + rect rgba(60, 120, 200, 0.2) + Note over App,Cloud: Phase 3: 追加リクエスト + App->>Cloud: [MSL] AccountQuery + Cloud-->>App: AccountQuery → 200 OK + App->>FTL: [MSL] /aleProvision #2 (RSA鍵交換) + FTL-->>App: aleProvision #2 → 新 PXA ESN 発行 + App->>App: proxyEsn.onKnown (新ESN保存) + App->>FTL: PromoProfileGateVideoDataQuery + FTL-->>App: 200 OK + NetflixId, SecureNetflixId [Cookie] + end + + rect rgba(150, 150, 150, 0.15) + Note over App,Cloud: Phase 4: 遅延リクエスト + App->>FTL: [MSL] FetchConfigData + App->>Cloud: [MSL] AccountQuery #2 + FTL-->>App: 200 OK + Cloud-->>App: 200 OK + end +``` + +--- + +## 3. フェーズ概要 + +### 3.1 Phase 0: ProxyESN 期限チェック + +アプリ起動時、`ProxyEsn.$init` でキャッシュ済み PXA ESN の期限がチェックされる。期限切れの場合はサーバーから新しい PXA ESN を取得する。 + +> **※ キャプチャ時の注意:** 今回のキャプチャでは Frida フック (`hook_msl.js`) が `ProxyEsn.$init` で `expired` フラグを強制的に `true` に書き換えているため、TTL が残っていても必ず再取得が発生する。通常の動作では TTL が有効ならキャッシュがそのまま使用される。 + +### 3.2 Phase 1: リクエスト送信 + +起動後、以下のリクエストが並列に送信される: + +1. **Appboot** — デバイス登録・Cookie 発行 +2. **getProxyEsn** (MSL) — PXA ESN 取得 +3. **aleProvision #1** (MSL) — RSA-OAEP-256 鍵交換 +4. **RenewSSOToken** (MSL → GraphQL) — SSO トークン更新 +5. **CurrentCountryQuery** — 国情報取得 + 認証 Cookie 発行 +6. **InterstitialForProfileGate / InterstitialForLolomo** (MSL → GraphQL) — UI 中間画面チェック + +### 3.3 Phase 2: レスポンス受信 + +Phase 1 のレスポンスが順次到着する。主要なデータ: + +- **CurrentCountryQuery** のレスポンスで `NetflixId` / `SecureNetflixId` Cookie が発行される +- **appboot** のレスポンスで `nfvdid` Cookie が更新され、`x-netflix-deviceidtoken` が発行される + +### 3.4 Phase 3: 追加リクエスト + +Phase 2 のレスポンスを受けて追加リクエストが送信される: + +1. **AccountQuery** (MSL → GraphQL) — アカウント情報取得 +2. **aleProvision #2** (MSL) — 再鍵交換 → PXA ESN 確定 +3. **PromoProfileGateVideoDataQuery** — プロフィール画面データ取得 + +### 3.5 Phase 4: 遅延リクエスト + +起動から約 30 秒後、バックグラウンドで送信される: + +1. **FetchConfigData** (MSL → Samurai) — デバイス・ストリーミング設定取得 +2. **AccountQuery #2** (MSL → GraphQL) — アカウント情報再取得 + +--- + +## 4. 鍵交換と暗号化フロー + +```mermaid +%%{init: {'theme':'dark'}}%% +sequenceDiagram + participant Client as Netflix App + participant Server as prod.ftl (MSL) + + Note over Client: RSA-2048 鍵ペア生成 + + Client->>Server: aleProvision #1
pubkey + scheme=A128GCM
type=SOCKETROUTER + Server-->>Client: A128GCM セッション鍵
(RSA-OAEP-256 で暗号化) + + Note over Client: セッション鍵復号
→ 以降の MSL 通信で使用 + + Client->>Server: aleProvision #2
同一 pubkey + Server-->>Client: 新セッション鍵 + PXA ESN + + Note over Client: PXA ESN 保存
→ MSL ヘッダに使用 +``` + +### 4.1 ALE Provision パラメータ + +| パラメータ | 値 | 説明 | +|---|---|---| +| `keyx.scheme` | `RSA-OAEP-256` | 鍵交換暗号化方式 (RSA with OAEP padding, SHA-256) | +| `keyx.data.pubkey` | Base64 RSA 2048-bit | クライアントが生成する使い捨て公開鍵 | +| `scheme` | `A128GCM` | セッション暗号化方式 (AES-128-GCM) | +| `type` | `SOCKETROUTER` | プロビジョニング種別 | +| `ver` | `1` | プロトコルバージョン | + +### 4.2 aleProvision が 2 回呼ばれる理由 + +1. **#1:** 起動直後に即座に鍵交換を開始。他のリクエストと並列。 +2. **#2:** getProxyEsn のレスポンスを受けた後に再度鍵交換。PXA ESN の発行と紐づく。 + +同一の RSA 公開鍵を使うが、サーバー側で新しいセッション鍵が発行される。 + +--- + +## 5. Cookie フロー + +```mermaid +%%{init: {'theme':'dark'}}%% +sequenceDiagram + participant App as Netflix App + participant AB as appboot + participant FTL as prod.ftl + participant Cloud as prod.cloud + + Note over App: 起動時: nfvdid (既存) のみ保持 + + App->>AB: POST /appboot (nfvdid Cookie なし) + App->>FTL: CurrentCountryQuery + App->>Cloud: [MSL] RenewSSOToken + + FTL-->>App: set-cookie: NetflixId=..., SecureNetflixId=... + Note over App: ← 認証 Cookie 取得 + + AB-->>App: set-cookie: nfvdid=... (更新)
x-netflix-deviceidtoken: ... + Note over App: ← デバイス Cookie 更新 + Device ID Token 取得 + + App->>FTL: PromoProfileGateVideoDataQuery + Note over App: NetflixId / SecureNetflixId Cookie を送信 + FTL-->>App: set-cookie: NetflixId=..., SecureNetflixId=... + Note over App: ← 認証 Cookie リフレッシュ +``` + +### 5.1 Cookie 一覧 + +| Cookie 名 | 発行元 | 用途 | 発行タイミング | +|---|---|---|---| +| `nfvdid` | appboot | デバイス識別子 | appboot レスポンス (set-cookie) | +| `NetflixId` | prod.ftl | ユーザー認証 | CurrentCountryQuery レスポンス | +| `SecureNetflixId` | prod.ftl | セキュア認証 (HTTPS only) | CurrentCountryQuery レスポンス | + +### 5.2 重要な観察 + +- **MSL リクエストは Cookie に依存しない。** MSL は独自の Master Token + User Auth Data で認証を行う。 +- **Non-MSL GraphQL は Cookie で認証する。** `NetflixId` / `SecureNetflixId` が必要。 +- **`nfvdid` は全リクエストに付与される**が、認証には直接使われない (デバイストラッキング用)。 + +--- + +## 6. ProxyESN ライフサイクル + +```mermaid +%%{init: {'theme':'dark'}}%% +stateDiagram-v2 + [*] --> Cached: アプリ前回終了時に保存 + Cached --> CheckTTL: 起動時に TTL チェック + CheckTTL --> Active: TTL 有効 → キャッシュ使用 + CheckTTL --> Requesting: TTL 期限切れ → /getProxyEsn 送信 + Requesting --> Provisioning: aleProvision #1 + #2 + Provisioning --> Received: proxyEsn.response + Received --> Active: proxyEsn.onKnown
(新 ESN キャッシュ保存) + Active --> [*]: MSL 通信で使用 +``` + +### 6.1 ESN の変化 + +| タイミング | ESN | +|---|---| +| 起動時 (キャッシュ) | `NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-02025HJ6UHN1D...` | +| 更新後 | `NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-0202DQTKQKE1UP...` | + +- `22594` = Widevine System ID (固定) +- 末尾の HMAC 部分のみが変化 (サーバー側で新規生成) + +--- + +## 7. セキュリティ上の観察 + +1. **二重認証メカニズム:** MSL (Master Token ベース) と Cookie (NetflixId ベース) の 2 系統が並行して使われる。 +2. **PXA ESN ローテーション:** PXA ESN は TTL ベースで管理され、期限切れ時にサーバーから新しい ESN を取得する。今回のキャプチャでは Frida が強制失効させているため、毎回再取得が発生している。 +3. **鍵の使い捨て:** aleProvision の RSA 鍵ペアはセッション単位で生成される (2048-bit)。 +4. **Persisted Query:** GraphQL クエリ本文をネットワーク上で送信しないため、API スキーマの隠蔽に寄与。 +5. **Cookie の分離:** `SecureNetflixId` は Secure 属性付きで HTTPS でのみ送信される。 +6. **並列リクエスト:** 起動時間を最小化するため、認証に必要な全リクエストが並列に送信される。 +7. **トークンの用途限定:** `ssoToken` は `RenewSSOToken` のみ、`x-netflix-deviceidtoken` は appboot レスポンスでのみ観測された。これらのトークンが HTTP レベルで他の API に転送される様子はなく、MSL レイヤー内部での使用またはローカル保持が示唆される。 diff --git a/docs/auth_flow_android_api.md b/docs/auth_flow_android_api.md new file mode 100644 index 0000000..d13a050 --- /dev/null +++ b/docs/auth_flow_android_api.md @@ -0,0 +1,326 @@ +# Netflix Android 認証フロー: API 詳細 + +> [auth_flow_android.md](auth_flow_android.md) のフロー概要と合わせて参照。 + +--- + +## 1. Phase 1: リクエスト送信 + +### 1.1 Appboot + +デバイス登録と `nfvdid` Cookie の発行。 + +**リクエスト:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| URL Path | `{BaseESN}` | デバイス Base ESN (`NFANDROID1-PRV-P-L3-...`) | +| URL Query | `keyVersion` | デバイス鍵バージョン (`1`) | +| URL Query | `suspended` | アカウント停止フラグ (`false`) | + +**レスポンス:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| Header | `Content-Type` | `application/x-msl+json` | +| Header | `x-netflix-deviceidtoken` | デバイス ID トークン (324 bytes) | +| Header | `x-netflix.nfstatus` | Netflix ステータスコード (`1_1`) | +| Cookie | `nfvdid` | デバイス ID Cookie (更新) | +| Body | — | MSL JSON ペイロード | + +> `x-netflix-deviceidtoken` は appboot レスポンスで発行されるが、後続の HTTP リクエストで送信される様子は観測されていない。MSL ペイロード内部またはローカル保持と推定される。 + +--- + +### 1.2 getProxyEsn (MSL) + +新しい PXA ESN をサーバーから取得する。 + +**リクエスト:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| URL | エンドポイント | `prod.ftl` → `/nq/androidui/samurai/~9.0.0/api` | +| Body (MSL) | `url` | `/getProxyEsn` | + +**レスポンス (MSL 復号後):** + +| 場所 | フィールド | 説明 | +|---|---|---| +| Body | `result.esn` | 新しい PXA ESN (`NFANDROID1-PXA-P-L3-...`) | +| Body | `serverTime` | サーバータイムスタンプ | +| Body | `from` | レスポンス元 (`playapi`) | + +> レスポンスは `proxyEsn.response` イベントで取得。アプリは `result.esn` を `proxyEsn.onKnown` でキャッシュに保存する。 + +--- + +### 1.3 aleProvision #1 (MSL) + +ALE (Application Level Encryption) のための RSA 鍵交換。 + +**リクエスト:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| URL | エンドポイント | `prod.ftl` → `/nq/androidui/samurai/~9.0.0/api` | +| Body (MSL) | `url` | `/aleProvision` | +| Body (MSL) | `params.provisionRequest.keyx.scheme` | `RSA-OAEP-256` — 鍵交換アルゴリズム | +| Body (MSL) | `params.provisionRequest.keyx.data.pubkey` | RSA 公開鍵 (2048-bit, Base64) | +| Body (MSL) | `params.provisionRequest.scheme` | `A128GCM` — セッション暗号化方式 | +| Body (MSL) | `params.provisionRequest.type` | `SOCKETROUTER` — プロビジョニング種別 | +| Body (MSL) | `params.netflixClientPlatform` | `androidNative` | +| Body (MSL) | `params.appVer` | `63928` | +| Body (MSL) | `params.mId` | `GOOGLPIXEL=4A==5G=S` — Model ID | +| Body (MSL) | `params.ffbc` | `phone` — Form Factor | + +**レスポンス:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| Body | — | MSL 暗号化 (A128GCM セッション鍵を RSA-OAEP-256 で暗号化して返すと推定) | + +--- + +### 1.4 RenewSSOToken (MSL → GraphQL) + +SSO トークンの更新。 + +**リクエスト:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| URL | エンドポイント | `prod.cloud` → `/graphql` | +| Body (MSL) | `operationName` | `RenewSSOToken` | +| Body (MSL) | `variables.ssoToken` | 既存の SSO トークン (`BgiHtuvcAxL4AYwX...`) | +| Body (MSL) | `extensions.persistedQuery.id` | `a4d00303-b02d-47c9-a53f-776b6a63b001` | +| Body (MSL) | `extensions.persistedQuery.version` | `102` | + +**レスポンス:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| Body | — | MSL 暗号化 (SSO トークン更新結果) | + +> `ssoToken` はこの API でのみ使用される。他の API で再利用される様子は観測されていない。 + +--- + +### 1.5 CurrentCountryQuery + +国情報取得と認証 Cookie の発行。Non-MSL GraphQL。 + +**リクエスト:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| URL | エンドポイント | `prod.ftl` → `/graphql` | +| Cookie | `nfvdid` | デバイス識別子 | +| Body | — | GraphQL JSON (平文) | + +**レスポンス:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| Header | `Content-Type` | `application/json;charset=UTF-8` | +| Cookie | `NetflixId` | ユーザー認証 Cookie (新規発行) | +| Cookie | `SecureNetflixId` | セキュア認証 Cookie (HTTPS only, 新規発行) | +| Body | — | 国情報 JSON | + +> 起動フローで最初に `NetflixId` / `SecureNetflixId` Cookie を発行するレスポンス。 + +--- + +### 1.6 InterstitialForProfileGate / InterstitialForLolomo (MSL → GraphQL) + +プロフィール選択画面・ホーム画面のインタースティシャル (中間画面) チェック。 + +**リクエスト:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| URL | エンドポイント | `prod.cloud` → `/graphql` | +| Body (MSL) | `operationName` | `InterstitialForProfileGate` / `InterstitialForLolomo` | +| Body (MSL) | `variables.format` | `HTML` | +| Body (MSL) | `variables.resolutionMode` | `ANDROID_XHDPI` | +| Body (MSL) | `variables.imageFormat` | `PNG` | +| Body (MSL) | `variables.commonParameters.isConsumptionOnly` | `true` | +| Body (MSL) | `extensions.persistedQuery.id` | `18f3ae27-a0f1-45c9-88e6-c6bd39159ecb` (ProfileGate) | +| Body (MSL) | `extensions.persistedQuery.version` | `102` | + +**レスポンス:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| Body | — | MSL 暗号化 (インタースティシャル表示の要否) | + +--- + +## 2. Phase 3: 追加リクエスト + +### 2.1 AccountQuery (MSL → GraphQL) + +アカウント基本情報の取得。 + +**リクエスト:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| URL | エンドポイント | `prod.cloud` → `/graphql` | +| Body (MSL) | `operationName` | `AccountQuery` | +| Body (MSL) | `extensions.persistedQuery.id` | `4043dd89-0ed5-4d7f-ac5c-40c7ffcec7ae` | +| Body (MSL) | `extensions.persistedQuery.version` | `102` | + +**レスポンス:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| Body | — | MSL 暗号化 (アカウント情報: プラン、プロフィール等) | + +--- + +### 2.2 aleProvision #2 (MSL) + +再鍵交換。#1 と同一の RSA 公開鍵を使用。 + +**リクエスト:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| URL | エンドポイント | `prod.ftl` → `/nq/androidui/samurai/~9.0.0/api` | +| Body (MSL) | — | aleProvision #1 と同一構造 (同一 RSA pubkey) | + +**レスポンス:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| Body | — | MSL 暗号化 (新セッション鍵 + PXA ESN) | + +> このレスポンスの直後に `proxyEsn.response` → `proxyEsn.onKnown` で新 PXA ESN が確定・キャッシュされる。 + +--- + +### 2.3 PromoProfileGateVideoDataQuery + +プロフィール画面データの取得。Non-MSL GraphQL。 + +**リクエスト:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| URL | エンドポイント | `prod.ftl` → `/graphql` | +| Cookie | `nfvdid` | デバイス識別子 | +| Cookie | `NetflixId` | ユーザー認証 Cookie | +| Cookie | `SecureNetflixId` | セキュア認証 Cookie | +| Body | — | GraphQL JSON (平文) | + +**レスポンス:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| Header | `Content-Type` | `application/json;charset=UTF-8` | +| Cookie | `NetflixId` | ユーザー認証 Cookie (リフレッシュ) | +| Cookie | `SecureNetflixId` | セキュア認証 Cookie (リフレッシュ) | +| Body | — | プロフィール画面データ JSON | + +--- + +## 3. Phase 4: 遅延リクエスト + +起動から約 30 秒後にバックグラウンドで送信される。 + +### 3.1 FetchConfigData (MSL → Samurai) + +デバイス・ストリーミング設定の一括取得。 + +**リクエスト:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| URL | エンドポイント | `prod.ftl` → `/nq/androidui/samurai/v1/config` | +| Body (MSL) | `method` | `get` | +| Body (MSL) | `path` | `["deviceConfig"]`, `["hendrixConfig"]`, `["networkScoreConfig"]`, `["accountConfig"]` 等 | +| Body (MSL) | `appType` | `samurai` | + +**レスポンス:** + +| 場所 | フィールド | 説明 | +|---|---|---| +| Body | — | MSL 暗号化 (設定データ) | + +--- + +### 3.2 AccountQuery #2 (MSL → GraphQL) + +Phase 3 の AccountQuery と同一。キャッシュ更新のための再取得。 + +--- + +## 4. GraphQL Persisted Query パターン + +Netflix Android は GraphQL の **Persisted Query** を使用する。クエリ本文は送信せず、事前登録済みの ID のみを指定する。 + +### 4.1 確認済みクエリ一覧 + +| operationName | persistedQuery ID | version | プロトコル | エンドポイント | +|---|---|---|---|---| +| `RenewSSOToken` | `a4d00303-b02d-47c9-a53f-776b6a63b001` | 102 | MSL | prod.cloud | +| `InterstitialForProfileGate` | `18f3ae27-a0f1-45c9-88e6-c6bd39159ecb` | 102 | MSL | prod.cloud | +| `InterstitialForLolomo` | *(未取得)* | 102 | MSL | prod.cloud | +| `AccountQuery` | `4043dd89-0ed5-4d7f-ac5c-40c7ffcec7ae` | 102 | MSL | prod.cloud | +| `CurrentCountryQuery` | *(未取得)* | — | non-MSL | prod.ftl | +| `PromoProfileGateVideoDataQuery` | *(未取得)* | — | non-MSL | prod.ftl | + +### 4.2 MSL GraphQL vs Non-MSL GraphQL + +| 特性 | MSL GraphQL | Non-MSL GraphQL | +|---|---|---| +| エンドポイント | `prod.cloud/graphql` | `prod.ftl/graphql` | +| ボディ暗号化 | MSL (CBOR → GZIP → JSON) | なし (平文 JSON) | +| 認証 | MSL Master Token | `NetflixId` / `SecureNetflixId` Cookie | +| レスポンス Cookie | `nfvdid` のみ | `nfvdid` + `NetflixId` + `SecureNetflixId` | + +--- + +## 5. 完全タイムライン + +| # | イベント | エンドポイント | API / 操作 | 備考 | +|---|---|---|---|---| +| 1 | `proxyEsn.forceExpired` | — | PXA ESN 期限チェック | Frida が強制失効 | +| 2 | `http.request` | appboot | `POST /appboot/{BaseESN}` | `keyVersion=1` | +| 3 | `proxyEsn.requestHeaders` | — | ヘッダー準備 | | +| 4 | `proxyEsn.request` | — | `/getProxyEsn` | | +| 5 | `msl.api` | prod.ftl | `/getProxyEsn` | MSL 平文 | +| 6 | `http.request` | prod.ftl | `samurai/~9.0.0/api` | MSL 暗号文 | +| 7 | `msl.api` | prod.ftl | `/aleProvision` #1 | RSA-OAEP-256 | +| 8 | `http.request` | prod.ftl | `samurai/~9.0.0/api` | MSL 暗号文 | +| 9 | `msl.api` | prod.cloud | `RenewSSOToken` | GraphQL Mutation | +| 10 | `http.request` | prod.cloud | `/graphql` | MSL 暗号文 | +| 11 | `http.request` | prod.ftl | `/graphql` | CurrentCountryQuery | +| 12 | `msl.api` | prod.cloud | `InterstitialForProfileGate` | | +| 13 | `msl.api` | prod.cloud | `InterstitialForLolomo` | | +| 14 | `http.request` | prod.cloud | `/graphql` | MSL 暗号文 | +| 15 | `http.request` | prod.cloud | `/graphql` | MSL 暗号文 | +| 16 | `http.response` | prod.ftl | CurrentCountryQuery | **+NetflixId [Cookie]** | +| 17 | `http.response` | prod.ftl | getProxyEsn | | +| 18 | `http.response` | prod.ftl | aleProvision #1 | | +| 19 | `msl.api` | prod.cloud | `AccountQuery` | | +| 20 | `http.request` | prod.cloud | `/graphql` | MSL 暗号文 | +| 21 | `http.response` | appboot | appboot | **+nfvdid [Cookie]** + DeviceIdToken | +| 22 | `http.response` | prod.cloud | RenewSSOToken | | +| 23 | `http.response` | prod.cloud | InterstitialForLolomo | | +| 24 | `http.response` | prod.cloud | InterstitialForProfileGate | | +| 25 | `http.response` | prod.cloud | AccountQuery | | +| 26 | `msl.api` | prod.ftl | `/aleProvision` #2 | 同一 RSA pubkey | +| 27 | `http.request` | prod.ftl | `samurai/~9.0.0/api` | MSL 暗号文 | +| 28 | `http.response` | prod.ftl | aleProvision #2 | | +| 29 | `proxyEsn.response` | — | 新 PXA ESN 受信 | | +| 30 | `proxyEsn.onKnown` | — | ESN キャッシュ保存 | `sn=8572399748193023` | +| 31 | `http.request` | prod.ftl | `/graphql` | PromoProfileGate | +| 32 | `http.response` | prod.ftl | PromoProfileGate | +NetflixId [Cookie] | +| 33 | `msl.api` | prod.ftl | FetchConfigData | Samurai config | +| 34 | `msl.api` | prod.cloud | `AccountQuery` #2 | | +| 35 | `http.request` | prod.ftl | `samurai/v1/config` | MSL 暗号文 | +| 36 | `http.request` | prod.cloud | `/graphql` | MSL 暗号文 | +| 37 | `http.response` | prod.cloud | AccountQuery #2 | | +| 38 | `http.response` | prod.ftl | FetchConfigData | | diff --git a/docs/esn_android.md b/docs/esn_android.md new file mode 100644 index 0000000..6f976cf --- /dev/null +++ b/docs/esn_android.md @@ -0,0 +1,459 @@ +# ESN (Electronic Serial Number) — Android 生成アルゴリズム + +> **対象:** Netflix Android v9.57.0 (build 63928) +> **デバイス:** Pixel 4a (5G) / bramble / Android 14 +> **取得方法:** Frida ヒープダンプ (`hook_esn.js` → `Java.choose()`) + +--- + +## 1. ESN の種類 + +Netflix Android では **4 種類の ESN** が使い分けられる。 + +| 種類 | メソッド | 用途 | 例 | +|---|---|---|---| +| **Base ESN (PRV)** | `b()` | デバイス登録・鍵交換 | `NFANDROID1-PRV-P-L3-GOOGLPIXEL=4A==5G=` | +| **Proxy ESN (PXA)** | `d()` | MSL 通信 (API コール) | `NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-0202C...` | +| **Logblob ESN** | `j()` | テレメトリ・ログ | `NFANDROID1-GOOGLPIXEL=4A==5G=S-3269A5B5...` | +| **Migration Identity** | `getMigrationIdentity()` | アカウント移行 | `isWidevine=true:systemId=22594:deviceId=DCDC...:wveaVersion=2` | + +--- + +## 2. ESN 構造 + +### 2.1 Base ESN (PRV) + +``` +NFANDROID1-PRV-P-L3-GOOGLPIXEL=4A==5G= +│ │ │ │ └─ sanitized(manufacturer_5 + model) +│ │ │ └─ Widevine Security Level (L1 or L3) +│ │ └─ Device Category (P=Phone, T=Tablet, B=TV, C=ChromeOS, E=Display) +│ └─ Type: PRV = Private (ローカル生成) +└─ Platform prefix (XOR 暗号化定数から復号) +``` + +### 2.2 Proxy ESN (PXA) + +``` +NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-0202CPU0AVUCDE... +│ │ │ │ │ │ └─ サーバー発行 fingerprint +│ │ │ │ │ └─ Widevine systemId +│ │ │ │ └─ sanitized model (PRV と同じ) +│ │ │ └─ Security Level +│ │ └─ Device Category +│ └─ Type: PXA = Proxy (サーバー発行) +└─ Platform prefix +``` + +PXA ESN はサーバーから MSL レスポンスで受信し、`SharedPreferences` に永続化される。 + +### 2.3 Logblob ESN + +``` +NFANDROID1-GOOGLPIXEL=4A==5G=S-3269A5B59F399B066558C80DCB36AE97... +│ │ └─ HMAC-SHA256(android_id, hmacKey) +│ └─ logblob model (末尾に 'S' suffix) +└─ Platform prefix +``` + +### 2.4 Migration Identity + +``` +isWidevine=true:systemId=22594:deviceId=DCDCCAA5D63D1F75...:wveaVersion=2 + │ └─ sanitize(hex(HMAC-SHA256(deviceUniqueId, hmacKey))) + └─ Widevine systemId +``` + +--- + +## 3. 構築アルゴリズム + +### 3.1 入力値 + +#### デバイス固有値 + +| 値 | 取得元 | 実測値 | +|---|---|---| +| `Build.MANUFACTURER` | `android.os.Build` | `Google` | +| `Build.MODEL` | `android.os.Build` | `Pixel 4a (5G)` | +| `android_id` | `Settings.Secure.getString()` | `ee6d59ac3434ec97` | +| Widevine `systemId` | `MediaDrm.getPropertyString("systemId")` | `22594` | +| Widevine `deviceUniqueId` | `MediaDrm.getPropertyByteArray("deviceUniqueId")` | `3c7f468d...` (32 bytes) | +| Widevine `securityLevel` | `MediaDrm.getPropertyString("securityLevel")` | `L3` (強制) | + +#### XOR 暗号化定数 (`o.feO` / jadx: `C12892feO`) + +`o.feO` クラスのコンストラクタで XOR 暗号 (`g()` メソッド) により復号される。 + +| フィールド | XOR seed | 文字数 | 復号値 | 用途 | +|---|---|---|---|---| +| `this.a` → `a()` | 263 | 11 | `NFANDROID1-` | Platform prefix | +| `this.d` → `e()` | 42043 | 14 | `20MNetflix2010` | HMAC-SHA256 秘密鍵 | +| `this.e` | 51941 | 52 | `amzn1.ask.skill.{uuid}` | Alexa skill ID | +| `this.b` → `b()` | 18671 | 124 | Base64 EC P-256 公開鍵 | 検証鍵 | + +> **XOR 復号アルゴリズム** (`g()` メソッド): +> ``` +> xor_base = -953881096168740883L ^ 7437109050103546004L +> for i in range(len(char_array)): +> long_val = (char_array[i] ^ (i * seed)) ^ xor_base +> result[i] = chr(long_val & 0xFFFF) +> ``` + +### 3.2 文字列サニタイズ + +2 段階のサニタイズが適用される。 + +#### Step 1: `jNY.c(str)` — 空白→アンダースコア + +``` +"Pixel 4a (5G)" → "Pixel_4a_(5G)" +``` + +- `str.trim().replaceAll("\\s", "_")` + +#### Step 2: `o.fkW.d(str)` (jadx: `C13218fkW.d`) — 大文字化 + 無効文字→`=` + +``` +"GooglPixel_4a_(5G)" → "GOOGLPIXEL=4A==5G=" +``` + +- `str.toUpperCase(Locale.US)` +- `[^A-Z0-9\\-=]` にマッチする文字を `=` に置換 + +#### 実例 + +``` +Build.MANUFACTURER = "Google" +Build.MODEL = "Pixel 4a (5G)" + +manufacturer_5 = "Google"[:5] = "Googl" +model_sanitized = jNY.c("Pixel 4a (5G)") = "Pixel_4a_(5G)" +combined = "Googl" + "Pixel_4a_(5G)" = "GooglPixel_4a_(5G)" +esn_model = fkW.d("GooglPixel_4a_(5G)") = "GOOGLPIXEL=4A==5G=" +``` + +### 3.3 Base ESN 構築 (`b()`) + +```java +// WidevineEntityAuthEsnProviderImpl.b() +String prefix = i(); // "NFANDROID1-PRV-P-L3-" +String model = Build.MODEL; // "Pixel 4a (5G)" +if (model.length() > 45) model = model.substring(0, 45); +String mfr = deviceModelProvider.e(); // "Googl" (first 5 chars) +String sanitizedModel = jNY.c(model); // "Pixel_4a_(5G)" +String combined = mfr + sanitizedModel; // "GooglPixel_4a_(5G)" +return prefix + fkW.d(combined); // "NFANDROID1-PRV-P-L3-GOOGLPIXEL=4A==5G=" +``` + +### 3.4 Prefix 構築 (`i()`) + +```java +// WidevineEntityAuthEsnProviderImpl.i() +StringBuilder sb = new StringBuilder(esnPrefixConfig.a()); // "NFANDROID1-" +sb.append("PRV-"); + +switch (deviceCategory) { + case PHONE: sb.append("P-"); break; + case TABLET: sb.append("T-"); break; + case CHROMEOS: sb.append("C-"); break; + case TV: + case STB: sb.append("B-"); break; + case DISPLAY: sb.append("E-"); break; +} + +if (cryptoProvider == WIDEVINE_L3) { + sb.append("L3-"); +} + +// 6番目の '-' までで切り詰め +return sb.toString().substring(0, indexOf6thDash + 1); +// → "NFANDROID1-PRV-P-L3-" +``` + +### 3.5 Migration Identity 構築 + +```java +// WidevineEntityAuthEsnProviderImpl.getMigrationIdentity() +String systemId = widevineSupport.b(); // "22594" +byte[] deviceUid = widevineSupport.d(); // 32 bytes +String hmacKey = esnPrefixConfig.e(); // "20MNetflix2010" + +Mac mac = Mac.getInstance("HmacSHA256"); +mac.init(new SecretKeySpec(hmacKey.getBytes(UTF_8), "HmacSHA256")); +byte[] hmacResult = mac.doFinal(deviceUid); +String hexHash = toUpperHex(hmacResult); // uppercase hex (64 chars) +String sanitized = fkW.d(hexHash); // already valid, no change + +return "isWidevine=true:systemId=" + systemId + + ":deviceId=" + sanitized + + ":wveaVersion=2"; +``` + +### 3.6 Logblob ESN 構築 (`j()`) + +```java +// WidevineEntityAuthEsnProviderImpl.j() +String platform = esnPrefixConfig.a(); // "NFANDROID1-" +String logModel = deviceModelProvider.a(); // "GOOGLPIXEL=4A==5G=S" +String hashId = deviceModelProvider.c(); // HMAC-SHA256(android_id, hmacKey) +return platform + logModel + "-" + hashId; +``` + +--- + +## 4. HMAC 計算の詳細 + +### 4.1 Migration Identity 用 (deviceUniqueId ベース) + +``` +Key: "20MNetflix2010" (UTF-8 bytes: 32304d4e6574666c697832303130) +Input: deviceUniqueId bytes (32 bytes from MediaDrm) + 3c7f468d8a9a78c0609acd65b0660b61a1178ec9978d9e4bcb71e53ff190d974 + +HMAC-SHA256 → DCDCCAA5D63D1F757C7B1D16C1D3AAA873112C30C1448E8E0510D78C9AA17DD4 +``` + +### 4.2 Logblob ESN 用 (android_id ベース) + +``` +Key: "20MNetflix2010" (UTF-8 bytes) +Input: android_id string as UTF-8 bytes + "ee6d59ac3434ec97" → 65653664353961633334333465633937 + +HMAC-SHA256 → 3269A5B59F399B066558C80DCB36AE97F9A8B477F8854DFED2C14C8453D6F556 +``` + +> **注意:** `deviceUniqueId` はバイナリ (MediaDrm 直接取得)、 +> `android_id` は文字列の UTF-8 バイト列として HMAC に渡される。 + +--- + +## 5. 永続化 (SharedPreferences) + +| キー | 値 | +|---|---| +| `nf_drm_esn` | PXA ESN (文字列) | +| `nf_drm_proxy_esn` | JSON: `{"esn": "...", "ts": epoch_ms, "sn": serial_number}` | +| `nf_drm_migration_identity` | Migration Identity 文字列 | + +--- + +## 6. クラスマッピング + +| 実行時クラス名 | jadx リネーム名 | 役割 | +|---|---|---| +| `o.feO` | `C12892feO` | EsnPrefixConfig — XOR 暗号化定数 | +| `o.fkW` | `C13218fkW` | EsnProviderUtils — 文字列サニタイズ | +| `o.fkn` | `C13235fkn` | DeviceModelProvider — モデル文字列加工 | +| `o.fey` | `InterfaceC12928fey` | EsnPrefixConfig interface | +| `o.jNK` | — | SharedPreferences ヘルパー | +| `o.jNY` | — | 文字列ユーティリティ (空白→`_`) | +| `o.jMn` | `C20944jMn` | Hex エンコーダ (uppercase) | +| `o.fkX` | `C13219fkX` | MigrationIdentity 文字列ビルダー | +| — | `WidevineEntityAuthEsnProviderImpl` | ESN 組み立て本体 | +| — | `WidevineSupportImpl` | Widevine DRM メタデータ | +| — | `DeviceIdentityUtils` | android_id → HMAC fingerprint | +| — | `ProxyEsn` | PXA ESN 管理 | + +--- + +## 7. プラットフォームプレフィックス一覧 + +今回のリバースエンジニアリング対象(Android APK + iOS IPA)から判明したプレフィックスは **3 種類のみ**。 +他プラットフォーム (Chromecast, Roku, Smart TV, PlayStation, Xbox 等) のプレフィックスは本調査範囲では発見されておらず、存在するかどうかも不明。 + +### 7.1 プラットフォームプレフィックス + +| プレフィックス | プラットフォーム | 発見元 | 備考 | +|---|---|---|---| +| `NFANDROID1-` | Android (Google Play) | `o.feO` の XOR 定数 (seed=263) | 主要 Android 向け | +| `NFANDROIDD-` | Android (非 GMS / デバッグ) | `jNG.java`, `CryptoProviderFactoryImpl.java` | L3 チェック条件にのみ出現 | +| `NFAPPL-` | iOS (Apple) | ログ / MSL トラフィック / `docs/msl_ios.md` | FairPlay ベース | + +> **`NFANDROIDD-` について:** +> `jNG.c()` と `CryptoProviderFactoryImpl.shouldKeepDeviceOnWidevineL3()` で +> `NFANDROID1-PRV-S-L3-` と並列でチェックされている。 +> `S` は特殊なプロビジョニング状態を示すと推測される(通常のトラフィックでは `P-` / `T-` 等のみ確認)。 + +### 7.2 デバイスカテゴリコード + +`DeviceCategory` enum から ESN の第 3 セグメントへのマッピング: + +| enum 値 | 文字列表現 | ESN コード | 備考 | +|---|---|---|---| +| `UNKNOWN` | `"unknown"` | *(なし)* | switch 文にマッチなし → コード付与されない | +| `PHONE` | `"phone"` | `P-` | | +| `TABLET` | `"tablet"` | `T-` | | +| `GOOGLE_TV` | `"google-tv"` | *(なし)* | switch 文にマッチなし | +| `ANDROID_TV` | — | `B-` | Box 系として統合 | +| `CHROME_OS` | — | `C-` | | +| `ANDROID_STB` | — | `B-` | ANDROID_TV と同じ `B-` | +| `SMART_DISPLAY` | `"smart-display"` | `E-` | | + +> **検出優先順位** (高→低): `CHROME_OS` → `ANDROID_STB` → `ANDROID_TV` → `TABLET` → `PHONE` (フォールバック) + +### 7.3 セキュリティレベルサフィックス + +`CryptoProvider` enum から ESN prefix 末尾へのマッピング: + +| enum 値 | ESN サフィックス | NCCP 値 | 備考 | +|---|---|---|---| +| `WIDEVINE_L1` | *(なし)* | 1 | L3 マーカーなし = L1 を暗示 | +| `WIDEVINE_L3` | `L3-` | 3 | 明示的に `L3-` が付与される | +| `NONE` | *(なし)* | 0 | | +| `OEM_CRYPTO` | *(なし)* | 4 | | +| `NATIVE` | *(なし)* | 5 | | + +### 7.4 プラットフォーム間の ESN 構造比較 + +| 属性 | Android | iOS | +|---|---|---| +| **プレフィックス** | `NFANDROID1-` / `NFANDROIDD-` | `NFAPPL-` | +| **DRM バージョン** | なし | `02` (FairPlay v2) | +| **ESN タイプ** | `PRV-` / `PXA-` (第 2 セグメント) | Base にはなし; PXA はモデル後に `-PXA-` | +| **デバイスカテゴリ** | `P-`/`T-`/`B-`/`C-`/`E-` (第 3 セグメント) | なし | +| **セキュリティレベル** | `L3-` or 省略 (=L1) | なし (FairPlay に L1/L3 区別なし) | +| **モデルエンコード** | `GOOGLPIXEL=4A==5G=` (mfr5 + model) | `IPHONE9=1` (hw model, `,`→`=`) | +| **システム ID** | Widevine `systemId` (PXA に付与) | なし | +| **ハッシュ/fingerprint** | 64 文字 hex (HMAC-SHA256) | 64 文字 hex (SHA-256) | + +### 7.5 全 ESN パターン一覧 + +``` +== Android == +Base (L1): NFANDROID1-PRV-P-{MODEL} +Base (L3): NFANDROID1-PRV-P-L3-{MODEL} +Proxy (L3): NFANDROID1-PXA-P-L3-{MODEL}-{systemId}-{fingerprint} +Logblob: NFANDROID1-{MODEL}S-{hmac_hash} +Migration: isWidevine=true:systemId={id}:deviceId={hash}:wveaVersion=2 +Debug L3 Check: NFANDROIDD-PRV-S-L3-... + +== iOS == +Base: NFAPPL-02-{MODEL}-{hash} +Proxy (PXA): NFAPPL-02-{MODEL}-PXA-{fingerprint} +device_type: NFAPPL-02- +``` + +--- + +## 8. セキュリティ上の所見 + +1. **HMAC 秘密鍵がハードコード:** `20MNetflix2010` は XOR 難読化されているのみで、静的解析で復元可能 +2. **XOR 暗号は脆弱:** 既知平文攻撃に対して脆弱。`g()` メソッドのシードと XOR base が判明すれば全定数を復号可能 +3. **ESN は再生成可能:** `android_id`、`Build.*` プロパティ、Widevine DRM 情報があれば ESN を再構築できる +4. **L3 強制:** ハードウェアが L1 対応でも `CryptoProvider.WIDEVINE_L3` でアプリは L3 モードで動作する +5. **PXA ESN の fingerprint:** サーバー発行であり、ローカルでは再現不可。`ProxyEsnMslRequest` で MSL 経由取得 + +--- + +## 9. ESN 生成フロー図 + +### 9.1 Base ESN (PRV) — ローカル生成 + +デバイス登録・鍵交換に使用される基本 ESN。全てローカルで生成される。 + +```mermaid +flowchart TD + subgraph inputs["入力データ"] + config["EsnPrefixConfig (XOR 復号)
platformPrefix = 'NFANDROID1-'"] + build_mfr["Build.MANUFACTURER
'Google'"] + build_model["Build.MODEL
'Pixel 4a (5G)'"] + cat["DeviceCategory
PHONE → 'P-'"] + level["CryptoProvider
WIDEVINE_L3 → 'L3-'"] + end + + config --> prefix_build["Prefix 構築"] + cat --> prefix_build + level --> prefix_build + prefix_build --> prefix["'NFANDROID1-PRV-P-L3-'"] + + build_mfr --> mfr5["先頭 5 文字
'Googl'"] + build_model --> trim["45 文字で切り詰め"] + trim --> spaces["空白 → '_'
'Pixel_4a_(5G)'"] + mfr5 --> combine["結合
'GooglPixel_4a_(5G)'"] + spaces --> combine + combine --> sanitize["大文字化 + 無効文字→'='
'GOOGLPIXEL=4A==5G='"] + + prefix --> result(["Base ESN (PRV)
NFANDROID1-PRV-P-L3-GOOGLPIXEL=4A==5G="]) + sanitize --> result + + style inputs fill:#1a1a2e,stroke:#e94560,color:#eee + style result fill:#0f3460,stroke:#e94560,color:#eee +``` + +### 9.2 Proxy ESN (PXA) — サーバー発行 + +MSL 通信 (API コール) で使用される ESN。サーバーが systemId と fingerprint を付与して発行する。 + +```mermaid +flowchart TD + base_esn["Base ESN (PRV)
NFANDROID1-PRV-P-L3-GOOGLPIXEL=4A==5G="] + + base_esn -->|"ProxyEsnMslRequest
MSL リクエスト"| server["Netflix Server"] + server -->|"PXA ESN 発行"| result(["Proxy ESN (PXA)
NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-0202CPU0AVUCDE..."]) + + result --> prefs["SharedPreferences 永続化
nf_drm_esn = PXA ESN
nf_drm_proxy_esn = JSON{esn, ts, sn}"] + + note["PXA の追加フィールド:
• systemId (Widevine) = 22594
• fingerprint = サーバー発行 (ローカル再現不可)"] + + style server fill:#533483,stroke:#e94560,color:#eee + style result fill:#0f3460,stroke:#e94560,color:#eee + style prefs fill:#16213e,stroke:#0f3460,color:#eee + style note fill:#1a1a2e,stroke:#555,color:#aaa +``` + +### 9.3 Logblob ESN — テレメトリ用 + +ログ送信時に使用される ESN。android_id から HMAC で生成。 + +```mermaid +flowchart TD + subgraph inputs["入力データ"] + config["EsnPrefixConfig (XOR 復号)
platformPrefix = 'NFANDROID1-'
hmacSecretKey = '20MNetflix2010'"] + aid["android_id
'ee6d59ac3434ec97'"] + model_prov["DeviceModelProvider
logblob model = 'GOOGLPIXEL=4A==5G=S'"] + end + + config -->|"hmacSecretKey"| hmac["HMAC-SHA256
key = '20MNetflix2010'
data = android_id (UTF-8 bytes)"] + aid --> hmac + hmac --> hash["ハッシュ (64 文字 hex)
'3269A5B59F399B06...'"] + + config -->|"platformPrefix"| concat["文字列結合"] + model_prov --> concat + hash --> concat + + concat --> result(["Logblob ESN
NFANDROID1-GOOGLPIXEL=4A==5G=S-3269A5B59F399B06..."]) + + style inputs fill:#1a1a2e,stroke:#e94560,color:#eee + style result fill:#0f3460,stroke:#e94560,color:#eee +``` + +### 9.4 Migration Identity — アカウント移行用 + +デバイス間のアカウント移行に使用される識別子。ESN 形式ではなくキーバリュー形式。 + +```mermaid +flowchart TD + subgraph inputs["入力データ"] + config["EsnPrefixConfig (XOR 復号)
hmacSecretKey = '20MNetflix2010'"] + drm_sid["Widevine systemId
'22594'"] + drm_uid["Widevine deviceUniqueId
(32 bytes: 3c7f468d...)"] + end + + config -->|"hmacSecretKey"| hmac["HMAC-SHA256
key = '20MNetflix2010'
data = deviceUniqueId (raw bytes)"] + drm_uid --> hmac + hmac --> hash["uppercase hex (64 文字)
'DCDCCAA5D63D1F75...'"] + hash --> sanitize["サニタイズ (変化なし)"] + + drm_sid --> build["文字列構築"] + sanitize --> build + + build --> result(["Migration Identity
isWidevine=true:systemId=22594:deviceId=DCDCCAA5D63D1F75...:wveaVersion=2"]) + + result --> prefs["SharedPreferences 永続化
nf_drm_migration_identity"] + + style inputs fill:#1a1a2e,stroke:#e94560,color:#eee + style result fill:#0f3460,stroke:#e94560,color:#eee + style prefs fill:#16213e,stroke:#0f3460,color:#eee +``` diff --git a/docs/instructions/android/http_headers_cookies.md b/docs/instructions/android/http_headers_cookies.md new file mode 100644 index 0000000..8aa3e84 --- /dev/null +++ b/docs/instructions/android/http_headers_cookies.md @@ -0,0 +1,258 @@ +# Netflix Android 9.57.0 — HTTP ヘッダー & Cookie リファレンス + +Frida `hook_headers.js` (Cronet フック) により 1,274 リクエストをキャプチャして整理。 + +--- + +## 通信経路 + +| 経路 | 用途 | ライブラリ | +|---|---|---| +| Cronet (メイン) | 通常 HTTP (API, config, GraphQL, logs) | `org.chromium.net.impl.CronetUrlRequest` | +| OkHttp | WebSocket 接続のみ | `okhttp3.internal.connection.RealCall` | + +--- + +## Cookie 一覧 + +| Cookie 名 | 出現頻度 | 説明 | +|---|---|---| +| `nfvdid` | 1,271/1,274 | Netflix Virtual Device ID。Base64url エンコードされたバイナリ | +| `flwssn` | 1,271/1,274 | Flow Session ID。UUID v4 形式 | +| `gsid` | 1,166/1,274 | GraphQL Session ID。UUID v4 形式。ログイン後に付与 | +| `NetflixId` | 105/1,274 | ユーザー認証 Cookie。未ログイン時は送信されない | +| `SecureNetflixId` | 105/1,274 | HMAC 署名付き認証 Cookie。NetflixId と常にペア | + +### Cookie 値の構造 + +``` +nfvdid=BQFmAAEBEEPj84LzHGpQ_ldxaVuQv8tgIBE9VAe3w-WeF5En4w5goMB6eLYVXqxblfzh23QC62wkeecr... + → Base64url エンコードされたバイナリトークン + +flwssn=183a999e-9ce9-4f29-ac9f-3f8b3982c817 + → UUID v4 (セッション単位で変化) + +gsid=d4779eb6-16d4-4179-89af-a069f7f1fc07 + → UUID v4 (ログイン後に生成) + +NetflixId=v%3D3%26ct%3DBgjHlOvcAxKoAo... + → URL エンコード済み。デコードすると: v=3&ct=&pg=&ch= + +SecureNetflixId=v%3D3%26mac%3DAQEAEQABABTPJ-U_...%26dt%3D1773402757629 + → URL エンコード済み。デコードすると: v=3&mac=&dt= +``` + +### Cookie の送信パターン + +| エンドポイント種別 | nfvdid | flwssn | gsid | NetflixId | SecureNetflixId | +|---|---|---|---|---|---| +| appboot | - | - | - | - | - | +| config (GET, 未ログイン) | ✓ | ✓ | - | ✓ | ✓ | +| graphql (ftl, 未ログイン) | ✓ | ✓ | - | ✓ | ✓ | +| pathEvaluator | ✓ | ✓ | - | ✓ | ✓ | +| cl/2 (ログ) | ✓ | ✓ | - | ✓ | ✓ | +| graphql (MSL 暗号化) | ✓ | ✓ | ✓ | - | - | +| config (POST, MSL) | ✓ | ✓ | ✓ | - | - | +| logblob | ✓ | ✓ | ✓ | - | - | + +**パターン**: MSL 暗号化 (`Content-Encoding: msl_v1`) リクエストでは `NetflixId`/`SecureNetflixId` が省略され、代わりに `gsid` が付与される。平文リクエストではその逆。 + +--- + +## ヘッダー一覧 + +### 共通ヘッダー (ほぼ全リクエストに存在) + +| ヘッダー | 値の例 | 説明 | +|---|---|---| +| `X-Netflix.Request.Client.Context` | `{"appState":"background","appView":"login"}` | アプリ状態の JSON | +| `X-Netflix.zuul.brotli.allowed` | `true` | Brotli 圧縮許可 | +| `X-Netflix.Request.Attempt` | `1` | リトライ番号 | +| `X-Netflix.Request.Id` | `afa97a02e3a349807ca65a0517e386c7` | リクエスト一意 ID (hex) | +| `X-Netflix.Client.Request.Name` | `FetchConfigDataWebRequest` | リクエスト種別名 | + +### デバイス識別ヘッダー + +| ヘッダー | 値 | 説明 | +|---|---|---| +| `X-Netflix.clientType` | `samurai` | クライアント種別 (Android = samurai) | +| `X-Netflix.deviceMemoryLevel` | `HIGH` | メモリレベル | +| `X-Netflix.appVer` | `9.57.0` | アプリバージョン | +| `X-Netflix.esnPrefix` | `NFANDROID1-PRV-P-L3-` | ESN プレフィックス (PRV) | +| `X-Netflix.androidApi` | `34` | Android API レベル | +| `X-Netflix.esn` | `NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-...` | フル PXA ESN | +| `X-Netflix.deviceFormFactor` | `PHONE` | フォームファクタ | + +### セッション・コンテキストヘッダー + +| ヘッダー | 値の例 | 説明 | +|---|---|---| +| `X-Netflix.session.id` | `1773552898264781980` | セッション ID (数値) | +| `x-netflix.client.current-profile-guid` | `ZEULH5S2GNGCRAABCSG6J2EGGA` | 現在のプロファイル GUID | +| `X-Netflix.request.uuid` | UUID v4 | リクエスト UUID | +| `X-Netflix.request.toplevel.uuid` | UUID v4 | トップレベルリクエスト UUID | +| `X-Netflix.tracing.cl.userActionId` | UUID v4 | ユーザーアクション追跡 ID | + +### コンテキストヘッダー (`x-netflix.context.*`) + +| ヘッダー | 値 | 説明 | +|---|---|---| +| `x-netflix.context.os-version` | `34` | OS バージョン | +| `x-netflix.context.app-version` | `9.57.0` | アプリバージョン | +| `x-netflix.context.locales` | `en-US` or `en-JP` | ロケール | +| `x-netflix.context.ui-flavor` | `android` | UI プラットフォーム | +| `x-netflix.context.form-factor` | `phone` | デバイス形状 | +| `x-netflix.context.android.installer-source` | `com.android.vending` | インストール元 | +| `x-netflix.context.operation-name` | `CurrentCountryQuery` | GraphQL オペレーション名 | +| `x-netflix.context.feature-capabilities` | `supportsStudioBranding` | 機能フラグ | +| `x-netflix.context.hawkins-version` | `5.13.0` | Hawkins バージョン | + +### MSL 関連ヘッダー + +| ヘッダー | 値 | 説明 | +|---|---|---| +| `Content-Encoding` | `msl_v1` | MSL 暗号化ボディ | +| `x-netflix.client.android.mslrequest` | `true` | MSL リクエストフラグ | +| `x-netflix.client.request.transport` | `http` | トランスポート種別 | + +### その他 + +| ヘッダー | 値 | 説明 | +|---|---|---| +| `X-Netflix-Internal-Volley-Priority` | `NORMAL` / `HIGH` / `null` | Volley 優先度 | +| `x-netflix.playback.main-content-viewable-id` | `81650338` | 再生中コンテンツ ID | +| `X-Netflix.Request.Routing` | JSON | ルーティング設定 | +| `X-Netflix.Request.NqTracking` | リクエスト名 | NQ 追跡用 | +| `debugRequest` | `true` | デバッグフラグ (ログ系) | +| `x-netflix.request.clcs.bucket` | `high` | CLCS バケット | + +--- + +## エンドポイント一覧 + +### 1. appboot (POST) +``` +POST https://android14.appboot.netflix.com/appboot/NFANDROID1-PRV-P-L3-?keyVersion=1&suspended=false +Content-Type: application/x-www-form-urlencoded +Cookies: なし +``` +アプリ起動時の初期化。ESN プレフィックスが URL パスに含まれる。Cookie なし。 + +### 2. config (GET) +``` +GET https://android14.prod.ftl.netflix.com/nq/androidui/samurai/v1/config?method=get&path=... +Cookies: nfvdid, flwssn, NetflixId, SecureNetflixId +``` +デバイス設定・機能フラグ取得。URL クエリに大量のデバイス情報を含む。 + +### 3. samurai API (POST) +``` +POST https://android14.prod.ftl.netflix.com/nq/androidui/samurai/~9.0.0/api?method=get&... +Cookies: nfvdid, flwssn (+ NetflixId/SecureNetflixId or gsid) +``` +メイン API エンドポイント。再生マニフェスト取得 (`licensedManifest`) 等。 + +### 4. GraphQL — ftl (POST) +``` +POST https://android14.prod.ftl.netflix.com/graphql +Content-Type: application/json +Cookies: nfvdid, flwssn, NetflixId, SecureNetflixId +``` +GraphQL API (平文)。`CurrentCountryQuery` 等。 + +### 5. GraphQL — cloud (POST, MSL) +``` +POST https://android.prod.cloud.netflix.com/graphql +Content-Encoding: msl_v1 +Content-Type: application/json +Cookies: flwssn, nfvdid, gsid +``` +GraphQL API (MSL 暗号化)。`InterstitialHook`, `AccountQuery` 等。 + +### 6. pathEvaluator (POST) +``` +POST https://android.prod.cloud.netflix.com/nq/aui/endpoint/^1.0.0-mobile/pathEvaluator +Cookies: nfvdid, flwssn, NetflixId, SecureNetflixId +``` +UI パス評価。Falcor ベースのデータフェッチ。 + +### 7. cl/2 ログ (POST) +``` +POST https://android14.logs.netflix.com/log/android/cl/2?TAG=LOG_CLV2 +Content-Type: application/json +Cookies: nfvdid, flwssn, NetflixId, SecureNetflixId +``` +クライアントログ送信 (平文)。 + +### 8. logblob (POST, MSL) +``` +POST https://android14.logs.netflix.com/log/android/logblob/1 +Content-Encoding: msl_v1 +Cookies: flwssn, nfvdid, gsid +``` +バイナリログ送信 (MSL 暗号化)。 + +--- + +## cURL 例 + +### config 取得 (全ヘッダー付き) +```bash +curl -X GET \ + "https://android14.prod.ftl.netflix.com/nq/androidui/samurai/v1/config?method=get&responseFormat=json" \ + -H "X-Netflix.clientType: samurai" \ + -H "X-Netflix.appVer: 9.57.0" \ + -H "X-Netflix.esnPrefix: NFANDROID1-PRV-P-L3-" \ + -H "X-Netflix.esn: NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-..." \ + -H "X-Netflix.androidApi: 34" \ + -H "X-Netflix.deviceFormFactor: PHONE" \ + -H "X-Netflix.deviceMemoryLevel: HIGH" \ + -H "X-Netflix.session.id: 7994808" \ + -H "X-Netflix.zuul.brotli.allowed: true" \ + -H "X-Netflix.Request.Attempt: 1" \ + -H "X-Netflix.Request.Id: $(uuidgen | tr -d '-')" \ + -H "X-Netflix.Client.Request.Name: FetchConfigDataWebRequest" \ + -H "X-Netflix.Request.Client.Context: {\"appState\":\"foreground\",\"appView\":\"home\"}" \ + -H "x-netflix.context.os-version: 34" \ + -H "x-netflix.context.app-version: 9.57.0" \ + -H "x-netflix.context.locales: en-US" \ + -H "x-netflix.context.ui-flavor: android" \ + -H "x-netflix.context.form-factor: phone" \ + -H "x-netflix.context.android.installer-source: com.android.vending" \ + -b "nfvdid=; flwssn=; NetflixId=; SecureNetflixId=" +``` + +### GraphQL (MSL 暗号化) +```bash +curl -X POST \ + "https://android.prod.cloud.netflix.com/graphql" \ + -H "Content-Type: application/json" \ + -H "Content-Encoding: msl_v1" \ + -H "X-Netflix.clientType: samurai" \ + -H "X-Netflix.appVer: 9.57.0" \ + -H "X-Netflix.esnPrefix: NFANDROID1-PRV-P-L3-" \ + -H "X-Netflix.androidApi: 34" \ + -H "X-Netflix.deviceFormFactor: PHONE" \ + -H "X-Netflix.deviceMemoryLevel: HIGH" \ + -H "X-Netflix.zuul.brotli.allowed: true" \ + -H "x-netflix.client.android.mslrequest: true" \ + -H "x-netflix.context.operation-name: AccountQuery" \ + -H "X-Netflix.Client.Request.Name: AccountQuery" \ + -H "X-Netflix.Request.Attempt: 1" \ + -H "X-Netflix.Request.Id: $(uuidgen | tr -d '-')" \ + -H "accept: multipart/mixed;deferSpec=20220824, application/graphql-response+json, application/json" \ + -b "flwssn=; nfvdid=; gsid=" \ + -d '' +``` + +--- + +## ESN の送信箇所 (HTTP ヘッダー経由) + +| ヘッダー | 値の種類 | 出現頻度 | +|---|---|---| +| `X-Netflix.esn` | フル PXA ESN | 707/1,274 | +| `X-Netflix.esnPrefix` | PRV プレフィックス (`NFANDROID1-PRV-P-L3-`) | 1,210/1,274 | +| URL パス (appboot) | PRV プレフィックス | appboot のみ | + +**注**: `X-Netflix.esn` は PXA (Proxy) ESN が送られる。PRV (Private) ESN はプレフィックスのみ。 diff --git a/docs/instructions/android/license_data.md b/docs/instructions/android/license_data.md new file mode 100644 index 0000000..8c078ed --- /dev/null +++ b/docs/instructions/android/license_data.md @@ -0,0 +1,375 @@ +# Netflix Widevine DRM License Exchange - Reference Data + +> Android アプリ (v9.57.0 / build 63928) から MSL 経由で取得した実データ。 +> 2026-03-13 にキャプチャ。デバイス: Pixel 4a (5G) / Android 14 / Widevine CDM v17.0.0 +> **環境**: L3 (ソフトウェア) 強制。レスポンスは Widevine decrypt → CBOR → GZIP → JSON で復元。 + +--- + +## 概要 + +Netflix Android アプリは **Widevine CDM** DRM を使用してコンテンツを保護している。 +iOS 版と異なり、マニフェスト取得と初回ライセンス取得が `/licensedManifest` として統合されている: + +1. **licensedManifest (PRE_FETCH)** → ストリーム一覧 + CDN URL + 初回 Widevine ライセンス (limited) を一括取得 +2. **License Request (standard)** → 再生開始時に standard ライセンスを取得 (Widevine CDM protobuf チャレンジ) +3. **License Response** → Widevine ライセンスバイナリを受信、コンテンツ鍵を取得 +4. **Release License** → 再生終了時にライセンスを解放 + +ライセンスリクエスト/レスポンスはすべて **Widevine CryptoContext** で暗号化された MSL ペイロードとして送信される。以下のデータはアプリ内部で MSL 暗号化される前/復号後の平文。 + +--- + +## エンドポイント + +``` +POST /nq/androidui/samurai/~9.0.0/api +``` + +MSL URL としては: +``` +/licensedManifest (マニフェスト + 初回ライセンス一括) +/license?licenseType={standard|limited}&playbackContextId={id}&esn={esn}&drmContextId={id} +``` + +### URL パラメータ + +| パラメータ | 説明 | +|---|---| +| `licenseType` | `standard` (再生開始時) or `limited` (PRE_FETCH / LDL) | +| `playbackContextId` | licensedManifest レスポンスで取得した再生セッション ID (Base64url-like) | +| `esn` | Netflix デバイス ESN。`NFANDROID1-PRV-P-{L3}-{MODEL}-{userId}-{fingerprint}` 形式 | +| `drmContextId` | DRM コンテキスト ID (数値)。licensedManifest レスポンスの `packageId` と対応 | + +--- + +## 1. licensedManifest Request (PRE_FETCH) + +コンテンツ選択時に送信される。iOS の `/manifest` + `/license` を統合した Android 固有の API。 +バッチリクエストにより複数 viewableId を同時に指定可能。 + +```json +{ + "version": 2, + "url": "/licensedManifest", + "languages": ["en-JP"], + "common": { + "challenge": "<共通 Widevine CDM protobuf>" + }, + "params": [ + { + "viewableId": "81756595", + "method": "licensedManifest", + "flavor": "PRE_FETCH", + "drmType": "widevine", + "manifestVersion": "v2", + "licenseType": "limited", + "challenges": { + "primary": [{ + "challengeBase64": "", + "drmSessionId": 1, + "clientTime": 1773373148 + }] + }, + "profiles": ["playready-h264mpl30-dash", "playready-h264hpl22-dash", "..."], + "profileGroups": [{"name": "primary", "profiles": ["..."]}] + } + ] +} +``` + +### iOS との違い + +| 項目 | iOS (`/manifest` + `/license`) | Android (`/licensedManifest`) | +|---|---|---| +| API パス | 分離 (2 リクエスト) | 統合 (1 リクエスト) | +| バッチ | 1 viewableId / リクエスト | 複数 viewableId / リクエスト | +| DRM チャレンジ | FairPlay SPC (個別) | `common.challenge` + 個別 `challenges.primary` | +| DRM タイプ | `fairplay` | `widevine` | +| チャレンジ形式 | FairPlay SPC (JSON: CHALLENGES[].PAYLOAD) | Widevine CDM protobuf (バイナリ) | + +--- + +## 2. licensedManifest Response + +L3 Widevine decrypt → CBOR (key 62) → GZIP 展開で復元。8 チャンク、合計 456KB。 + +```json +{ + "id": 1, + "version": 2, + "serverTime": 1773373149628, + "result": [ + { + "movieId": "81756595", + "packageId": "2596051", + "duration": 8523000, + "drmContextId": "2596051", + "playbackContextId": "E3-Bgj5tevc...", + "video_tracks": [{"..."}], + "audio_tracks": [{"..."}], + "timedtexttracks": [{"..."}], + "servers": [{"..."}], + "links": { + "events": {"href": "/events?playbackContextId=...&esn=..."}, + "ldl": {"href": "/license?licenseType=limited&..."}, + "license": {"href": "/license?licenseType=standard&..."} + } + } + ] +} +``` + +### result フィールド解説 + +| フィールド | 型 | 説明 | +|---|---|---| +| `movieId` | string | コンテンツ ID | +| `packageId` | string | DRM パッケージ ID (`drmContextId` と対応) | +| `duration` | number | 再生時間 (ミリ秒)。`8523000` = 2時間22分3秒 | +| `drmContextId` | string | DRM コンテキスト ID | +| `playbackContextId` | string | 再生セッション ID (後続の `/license`, `/events` URL に埋め込み) | +| `video_tracks` | array | 映像トラック一覧 (複数ビットレート/解像度) | +| `audio_tracks` | array | 音声トラック一覧 (多言語 × 複数ビットレート) | +| `timedtexttracks` | array | 字幕トラック一覧 (IMSC 1.1) | +| `servers` | array | CDN サーバー一覧 (Open Connect Appliance) | +| `links` | object | 後続 API エンドポイント (`events`, `ldl`, `license`) | + +### video_tracks の構造 + +```json +{ + "trackType": "PRIMARY", + "new_track_id": "V:2:1;2;;primary;-1;none;-1;", + "dimensionsLabel": "2D", + "streams": [ + { + "content_profile": "playready-h264hpl30-dash", + "bitrate": 1050, + "peakBitrate": 2250, + "res_w": 960, + "res_h": 540, + "framerate_value": 24000, + "framerate_scale": 1001, + "size": 1181780966, + "downloadable_id": "1496730611", + "vmaf": 87, + "isDrm": true + } + ] +} +``` + +L3 環境での最大解像度は **960x540** (SD)。7 ストリーム (80〜1050 kbps)。 + +### servers の構造 + +```json +{ + "id": 140368, + "name": "c062.osa001.ix.nflxvideo.net", + "rank": 1, + "type": "OPEN_CONNECT_APPLIANCE", + "dns": { + "host": "ipv4-c062-osa001-ix.1.oca.nflxvideo.net", + "ipv4": "45.57.82.139", + "ipv6": null + } +} +``` + +3 台の Open Connect Appliance (大阪: osa001, osa003)。`rank` でフェイルオーバー優先度を指定。 + +### iOS manifest レスポンスとの比較 + +| 項目 | iOS (`/manifest`) | Android (`/licensedManifest`) | +|---|---|---| +| レスポンス取得 | **未取得** | **復元済み** (L3 decrypt 経由) | +| result 構造 | 単一オブジェクト (推定) | 配列 (バッチ対応) | +| links | events, license (推定) | events, ldl, license | +| servers | CDN URL 一覧 (推定) | Open Connect Appliance (id, name, rank, dns) | +| video_tracks | 不明 | streams[] (bitrate, res, vmaf, downloadable_id) | + +--- + +## 3. Standard License Request + +再生開始時に送信される。licensedManifest レスポンスの `links.license.href` を使用。 + +```json +{ + "version": 2, + "url": "/license?licenseType=standard&playbackContextId=E3-Bgj5tevc...&esn=NFANDROID1-PRV-P-L3-GOOGLPIXEL%3D4A%3D%3D5G%3D-22594-...&drmContextId=2596051", + "params": { + "clientTime": 1773373168, + "challengeBase64": "", + "xid": "7616579701369171345" + }, + "languages": ["en-JP"] +} +``` + +### params フィールド解説 + +| フィールド | 型 | 説明 | +|---|---|---| +| `clientTime` | number | クライアント時刻 (Unix epoch, seconds) | +| `challengeBase64` | string | Base64 エンコードされた Widevine CDM protobuf ライセンスチャレンジ | +| `xid` | string | リクエスト ID (トランザクション ID) | + +### challengeBase64 の内容 + +iOS の FairPlay SPC が JSON (`CHALLENGES[].PAYLOAD`) であるのに対し、Widevine は **protobuf バイナリ**。 +内部に以下の情報が含まれる: + +| フィールド | 値 | 説明 | +|---|---|---| +| `esn` | `NFANDROID1-PRV-P-L3-GOOGLPIXEL=4A==5G=-22594-...` | デバイス ESN (L3 マーカー含む) | +| `movieid` | `"81756595"` | コンテンツ ID | +| `issuetime` | `1773373148` | 発行時刻 | +| `salt` | `"35982634558192635..."` | ランダムソルト | +| `oem_crypto_build_information` | `"OEMCrypto Level3 Code"` | OEMCrypto セキュリティレベル | +| `widevine_cdm_version` | `"17.0.0"` | CDM バージョン | +| `device_name` | `"bramble"` | デバイスコードネーム | +| `architecture_name` | `"arm64-v8a"` | CPU アーキテクチャ | + +--- + +## 4. Standard License Response + +```json +{ + "id": 1, + "version": 2, + "serverTime": 1773373169221, + "result": { + "licenseResponseBase64": "", + "secureStopExpected": false, + "links": { + "releaseLicense": { + "rel": "releaseLicense", + "href": "/releaseLicense?drmLicenseContextId=E3-Bgj5tevc...;EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED;STANDARD;1773373169202" + } + }, + "drmGroupId": "132", + "licenseType": "standard", + "expiration": 1773373168000 + }, + "common": {}, + "from": "playapi" +} +``` + +### result フィールド解説 + +| フィールド | 型 | 説明 | +|---|---|---| +| `licenseResponseBase64` | string | Base64 エンコードされた Widevine ライセンスバイナリ (protobuf)。`provideKeyResponse()` に渡す | +| `secureStopExpected` | boolean | Secure Stop (再生終了通知) が必要か | +| `links.releaseLicense.href` | string | ライセンス解放用 URL。再生終了時にこの URL にリクエストを送る | +| `drmGroupId` | string | DRM グループ ID。`"132"` (iOS は `"191-192"` のようにハイフン区切り) | +| `licenseType` | string | `"standard"` | +| `expiration` | number | ライセンス有効期限 (Unix epoch, milliseconds) | + +### licenseResponseBase64 の内容 + +iOS の FairPlay CKC が JSON (`VERSION`, `MEDIASESSIONID`, `RESPONSES[].PAYLOAD`) であるのに対し、 +Widevine のライセンスレスポンスは **protobuf バイナリ**。内部には: + +- **暗号化されたコンテンツ鍵**: Widevine CDM のセッション鍵で暗号化 +- **埋め込み JSON**: `version`, `esn`, `issuetime`, `movieid`, `salt` +- **鍵 ID**: `CE66E4B447F964AC19000000000000000` +- **ライセンスメタデータ**: security_level, expiry_duration + +`android.media.MediaDrm.provideKeyResponse(sessionId, licenseResponseBase64)` に渡すことで +コンテンツ復号鍵が Widevine CDM にインストールされる。 + +--- + +## 5. Release License (再生終了) + +再生終了時、サーバーに通知する。`links.releaseLicense.href` を使用。 + +iOS と同じく、レスポンスは空の `actions` オブジェクト (推定): + +```json +{ + "result": [ + { + "actions": {} + } + ], + "id": 1, + "common": {}, + "from": "playapi", + "serverTime": 1773373xxx000, + "version": 2 +} +``` + +--- + +## 6. drmSessionId / videoTrackName フォーマット + +``` +V:2:1;2;;primary;-1;none;-1; +``` + +セミコロン区切り。推定される構造: +- `V:2:1` — ビデオトラック識別 (V=Video, 以降はインデックス) +- `2` — DRM グループ数 +- (空) — 予約 +- `primary` — プロファイルグループ (iOS は `ce4` = HEVC) +- `-1` — 品質レベル (-1 = auto) +- `none` — HDR タイプ (none = SDR) +- `-1` — 予約 + +--- + +## 7. ESN (Entertainment Service Name) フォーマット + +``` +NFANDROID1-PRV-P-L3-GOOGLPIXEL=4A==5G=-22594-3E369F1C9B189ED664E13DEEEE9BACB67684B4FE6283537D1A67810BFE73E371 +``` + +- `NFANDROID1` — Netflix Android プラットフォーム (iOS は `NFAPPL`) +- `PRV` — プロビジョニングタイプ +- `P` — 製品タイプ +- `L3` — Widevine セキュリティレベル (L3=ソフトウェア、L1=TEE)。iOS には存在しない +- `GOOGLPIXEL=4A==5G=` — デバイスモデル (Pixel 4a (5G)、`=` は `,` のエスケープ) +- `22594` — ユーザー ID +- 末尾 64 文字 — デバイス固有ハッシュ (SHA-256) + +### iOS ESN との比較 + +| 項目 | iOS | Android | +|---|---|---| +| プレフィックス | `NFAPPL` | `NFANDROID1` | +| DRM バージョン | `02` (FairPlay v2) | なし | +| セキュリティレベル | なし | `L3` or `L1` | +| モデル | `IPHONE9=1` | `GOOGLPIXEL=4A==5G=` | +| ユーザー ID | なし | あり (`22594`) | + +--- + +## 8. データの関連性まとめ + +``` +licensedManifest Response + ├─ playbackContextId ──→ /license Request URL + ├─ drmContextId / packageId ──→ /license Request URL + ├─ video_tracks / audio_tracks ──→ CDN ストリーム URL + ├─ servers[] ──→ CDN サーバー (Open Connect Appliance) + └─ links + ├─ events ──→ /events URL (再生イベント報告) + ├─ ldl ──→ /license?licenseType=limited (LDL) + └─ license ──→ /license?licenseType=standard + +License Request + └─ challengeBase64 (Widevine protobuf) ──→ License Response.licenseResponseBase64 + +License Response + ├─ licenseResponseBase64 ──→ MediaDrm.provideKeyResponse() → コンテンツ鍵 + ├─ links.releaseLicense.href ──→ Release License Request URL + └─ expiration ──→ standard: ライセンス有効期限 +``` diff --git a/docs/instructions/android/license_exchange.json b/docs/instructions/android/license_exchange.json new file mode 100644 index 0000000..5175269 --- /dev/null +++ b/docs/instructions/android/license_exchange.json @@ -0,0 +1,169 @@ +{ + "_description": "Netflix Android Widevine DRM license exchange data (captured 2026-03-13, L3 forced). Payloads are truncated for size. challengeBase64 contains Widevine CDM protobuf.", + "_environment": "Widevine L3 (software) forced. Device: Pixel 4a (5G) / Android 14 / CDM v17.0.0", + "licensed_manifest_request": { + "_note": "Android combines /manifest + /license into single /licensedManifest request. Supports batch (multiple viewableIds).", + "url": "/licensedManifest", + "method": "licensedManifest", + "flavor": "PRE_FETCH", + "drmType": "widevine", + "manifestVersion": "v2", + "licenseType": "limited", + "params_per_viewable": { + "viewableId": "81756595", + "challenges": { + "primary": [{ + "challengeBase64": "", + "drmSessionId": 1, + "clientTime": 1773373148 + }] + }, + "common_challenge": "" + }, + "batch_viewable_ids": ["81756595", "80243261", "81774276"] + }, + "licensed_manifest_response": { + "_note": "Recovered via L3 Widevine decrypt → CBOR (key 62) → GZIP → JSON. 8 chunks, 456KB total.", + "id": 1, + "version": 2, + "serverTime": 1773373149628, + "result": [ + { + "_viewable": "81756595 (MOVIE, 142 min)", + "movieId": "81756595", + "packageId": "2596051", + "duration": 8523000, + "drmContextId": "2596051", + "playbackContextId": "E3-Bgj5tevcAxKrBD8Cc_xgU8lnmqvxiyt4LKYtO-sU... (truncated)", + "video_tracks": [ + { + "trackType": "PRIMARY", + "new_track_id": "V:2:1;2;;primary;-1;none;-1;", + "dimensionsLabel": "2D", + "streams_summary": [ + {"content_profile": "playready-h264hpl22-dash", "bitrate": 80, "res_w": 480, "res_h": 270, "isDrm": true}, + {"content_profile": "playready-h264hpl22-dash", "bitrate": 100, "res_w": 480, "res_h": 270, "isDrm": true}, + {"content_profile": "playready-h264hpl30-dash", "bitrate": 200, "res_w": 608, "res_h": 342, "isDrm": true}, + {"content_profile": "playready-h264hpl30-dash", "bitrate": 350, "res_w": 608, "res_h": 342, "isDrm": true}, + {"content_profile": "playready-h264hpl30-dash", "bitrate": 560, "res_w": 768, "res_h": 432, "isDrm": true}, + {"content_profile": "playready-h264hpl30-dash", "bitrate": 750, "res_w": 768, "res_h": 432, "isDrm": true}, + {"content_profile": "playready-h264hpl30-dash", "bitrate": 1050, "res_w": 960, "res_h": 540, "isDrm": true} + ], + "_note": "L3 max resolution: 960x540. L1 would include up to 1920x1080+ with HEVC HDR10." + } + ], + "audio_tracks_summary": { + "languages": ["ja", "en", "pt-BR", "es", "es-ES", "fr", "de", "it", "pl", "fil", "hu"], + "profiles": ["xheaac-dash", "heaac-2-dash"], + "channels": "2.0", + "bitrates": [32, 64, 96, 192], + "total_tracks": 19, + "_note": "Each language has xheaac-dash + heaac-2-dash tracks with 4 bitrate options" + }, + "timedtexttracks_summary": { + "total_tracks": 57, + "format": "imsc1.1", + "languages": ["en", "ja", "de", "es", "es-ES", "fr", "fil", "hu", "it", "pl", "pt-BR"], + "types": ["subtitles", "closedcaptions"], + "_note": "Each has downloadableIds.imsc1.1 with CDN URLs and sha1 hash" + }, + "servers": [ + { + "id": 140368, + "name": "c062.osa001.ix.nflxvideo.net", + "rank": 1, + "type": "OPEN_CONNECT_APPLIANCE", + "dns": {"host": "ipv4-c062-osa001-ix.1.oca.nflxvideo.net", "ipv4": "45.57.82.139"} + }, + { + "id": 140566, + "name": "c010.osa003.ix.nflxvideo.net", + "rank": 2, + "type": "OPEN_CONNECT_APPLIANCE", + "dns": {"host": "ipv4-c010-osa003-ix.1.oca.nflxvideo.net", "ipv4": "23.246.52.11"} + }, + { + "id": 159199, + "name": "c106.osa001.ix.nflxvideo.net", + "rank": 3, + "type": "OPEN_CONNECT_APPLIANCE", + "dns": {"host": "ipv4-c106-osa001-ix.1.oca.nflxvideo.net", "ipv4": "45.57.84.3"} + } + ], + "links": { + "events": { + "href": "/events?playbackContextId=E3-Bgj5tevc...&esn=NFANDROID1-PRV-P-L3-GOOGLPIXEL%3D4A%3D%3D5G%3D-22594-...", + "rel": "events" + }, + "ldl": { + "href": "/license?licenseType=limited&playbackContextId=E3-Bgj5tevc...&esn=NFANDROID1-PRV-P-L3-...&drmContextId=2596051", + "rel": "license" + }, + "license": { + "href": "/license?licenseType=standard&playbackContextId=E3-Bgj5tevc...&esn=NFANDROID1-PRV-P-L3-...&drmContextId=2596051", + "rel": "license" + } + } + } + ], + "_note_batch": "result is an array with one entry per viewableId. 3 entries in this capture." + }, + "standard_license_request": { + "_note": "Sent after /licensedManifest, using playbackContextId from manifest response links.license.href", + "url": "/license?licenseType=standard&playbackContextId=E3-Bgj5tevcAxKrBD8Cc_xgU8lnmqvxiy...&esn=NFANDROID1-PRV-P-L3-GOOGLPIXEL%3D4A%3D%3D5G%3D-22594-...&drmContextId=2596051", + "params": { + "clientTime": 1773373168, + "challengeBase64": "", + "challengeBase64_contents": { + "device_certificate": { + "esn": "NFANDROID1-PRV-P-L3-GOOGLPIXEL=4A==5G=-22594-3E369F1C9B189ED664E13DEEEE9BACB67684B4FE6283537D1A67810BFE73E371", + "movieid": "81756595", + "issuetime": 1773373148, + "salt": "3598263455819263519576642966605" + }, + "oem_crypto_build_information": "OEMCrypto Level3 Code May 20 2022 21:36:54", + "widevine_cdm_version": "17.0.0", + "device_name": "bramble", + "architecture_name": "arm64-v8a" + }, + "xid": "7616579701369171345" + }, + "languages": ["en-JP"] + }, + "standard_license_response": { + "_note": "Recovered via L3 Widevine decrypt → CBOR (key 62) → GZIP → JSON", + "id": 1, + "version": 2, + "serverTime": 1773373169221, + "result": { + "licenseResponseBase64": "", + "licenseResponseBase64_contents": { + "_note": "Unlike iOS FairPlay (JSON with CHALLENGES/RESPONSES), Widevine license is a protobuf binary", + "embedded_json": { + "version": "1.0", + "esn": "NFANDROID1-PRV-P-L3-GOOGLPIXEL=4A==5G=-22594-...", + "issuetime": 1773373148, + "movieid": "81756595", + "salt": "35982634558192635195766429666057" + }, + "license_metadata": { + "security_level": "0x0784", + "expiry_duration": "0xC8D102", + "key_id": "CE66E4B447F964AC190000000000000" + } + }, + "secureStopExpected": false, + "links": { + "releaseLicense": { + "href": "/releaseLicense?drmLicenseContextId=E3-Bgj5tevc...;EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED;STANDARD;1773373169202", + "rel": "releaseLicense" + } + }, + "expiration": 1773373168000, + "drmGroupId": "132", + "licenseType": "standard" + }, + "common": {}, + "from": "playapi" + } +} diff --git a/docs/instructions/android/msl_cbor_keys.md b/docs/instructions/android/msl_cbor_keys.md new file mode 100644 index 0000000..4d97f74 --- /dev/null +++ b/docs/instructions/android/msl_cbor_keys.md @@ -0,0 +1,734 @@ +# MSL CBOR 整数キーマッピング — リファレンス + +MSL プロトコルの CBOR エンコード時に使用される固定整数キー。 +JSON の文字列キーをサイズ削減のため整数に置換する仕組みで、`MslEncoderFactory` にハードコードされている。 + +353 件の暗号化前 MessageHeader + 403 件の PayloadChunk の CBOR 解析により確定。 + +--- + +## 全整数キー一覧 + +| CBOR キー | JSON フィールド名 | 使用コンテキスト | +|---|---|---| +| `11` | `issuedate` | UserIdToken.tokendata | +| `12` | `mtserialnum` | UserIdToken.tokendata | +| `13` | `expiration` | UserIdToken.tokendata | +| `14` | `sequencenumber` | PayloadChunk | +| `15` | `tokendata` | MasterToken, UserIdToken, keyrequestdata (共通) | +| `16` | `signature` | MasterToken, UserIdToken, keyrequestdata (共通) | +| `17` | `mastertoken` | MessageHeader | +| `18` | `useridtoken` | MessageHeader | +| `19` | `renewable` | MessageHeader | +| `20` | `sender` | MessageHeader | +| `21` | `handshake` | MessageHeader | +| `22` | `messageid` | MessageHeader, PayloadChunk | +| `24` | `timestamp` | MessageHeader | +| `25` | `serialnumber` | MasterToken.tokendata, UserIdToken.tokendata | +| `26` | `renewable` | MasterToken.tokendata | +| `27` | `issuer` | MasterToken.tokendata | +| `28` | `identity` | MasterToken.tokendata | +| `29` | `userdata` | UserIdToken.tokendata | +| `30` | `scheme` | keyrequestdata, userauthdata | +| `31` | `keydata` | keyrequestdata | +| `35` | `authdata` | userauthdata | +| `36` | `capabilities` | MessageHeader | +| `37` | `compressionalgos` | capabilities | +| `38` | `languages` | capabilities | +| `39` | `encoders` | capabilities | +| `40` | `peer` | MessageHeader | +| `41` | `nonreplayableid` | MessageHeader | +| `42` | `keyrequestdata` | MessageHeader | +| `43` | `sequencenumber` | MasterToken.tokendata | +| `44` | `compressionalgo` | PayloadChunk | +| `47` | `userauthdata` | MessageHeader | +| `50` | `cdmsg` | keyrequestdata.keydata (Widevine) | +| `56` | `netflixid` | userauthdata.authdata (NETFLIXID) | +| `60` | `securenetflixid` | userauthdata.authdata (NETFLIXID) | +| `62` | `data` | PayloadChunk | +| `63` | `endofmsg` | PayloadChunk | + +## 文字列キー (整数マッピングなし) + +| キー | 使用コンテキスト | +|---|---| +| `maxpayloadchunksize` | capabilities | +| `profileid` | UserIdToken.tokendata | +| `duid` | keyrequestdata.keydata | + +--- + +## MessageHeader 構造 + +``` +MessageHeader (暗号化前 headerdata): +├─ 17: mastertoken → list[ {15: tokendata, 16: signature} ] +├─ 18: useridtoken → {15: tokendata, 16: signature} +├─ 19: renewable → bool +├─ 20: sender → string (ESN) +├─ 21: handshake → bool +├─ 22: messageid → int +├─ 24: timestamp → int (Unix秒) +├─ 36: capabilities +│ ├─ 37: compressionalgos → list +│ ├─ 38: languages → list +│ ├─ 39: encoders → list +│ └─ maxpayloadchunksize → int +├─ 40: peer → bool +├─ 41: nonreplayableid → int +├─ 42: keyrequestdata → list[ {30: scheme, 31: keydata} ] +└─ 47: userauthdata → {30: scheme, 35: authdata} +``` + +## PayloadChunk 構造 + +``` +PayloadChunk (暗号化前): +├─ 14: sequencenumber → int +├─ 22: messageid → int +├─ 44: compressionalgo → string ("GZIP") +├─ 62: data → bytes (GZIP圧縮ペイロード) +└─ 63: endofmsg → bool +``` + +## MasterToken tokendata 構造 + +``` +MasterToken.tokendata (key 17[].15 を CBORデコード): +├─ 25: serialnumber → int +├─ 26: renewable → bool +├─ 27: issuer → string ("sf", "cad") +├─ 28: identity → bytes (暗号化済み) +└─ 43: sequencenumber → int +``` + +## UserIdToken tokendata 構造 + +``` +UserIdToken.tokendata (key 18.15 を CBORデコード): +├─ 11: issuedate → int (Unix秒) +├─ 12: mtserialnum → int (紐づくMasterTokenシリアル) +├─ 13: expiration → int (Unix秒, 発行から14日後) +├─ 25: serialnumber → int +├─ 29: userdata → bytes (暗号化済み) +└─ profileid → string (プロファイルGUID) +``` + +## userauthdata 構造 (NETFLIXID スキーム) + +``` +userauthdata (key 47): +├─ 30: scheme → "NETFLIXID" +└─ 35: authdata + ├─ 56: netflixid → string (URLエンコード) + │ 例: v=3&mac=AQEAEQABABQxZyPp7r91RNj4pmPTIatYcL3jjIUVbvQ.&dt=1773392547767 + └─ 60: securenetflixid → string (URLエンコード) + 例: v=3&ct=<暗号化トークン>&pg=<プロファイルGUID>&ch= +``` + +### netflixid のパラメータ + +| パラメータ | 説明 | +|---|---| +| `v` | バージョン (`3`) | +| `mac` | HMAC 署名 | +| `dt` | デバイスタイムスタンプ (Unix ms) | + +### securenetflixid のパラメータ + +| パラメータ | 説明 | +|---|---| +| `v` | バージョン (`3`) | +| `ct` | 暗号化認証トークン (protobuf base64url) | +| `pg` | プロファイル GUID | +| `ch` | チャネル HMAC | + +--- + +## ユーザー認証フロー + +1. **初回認証 (1回)**: key 47 (`userauthdata`) に NETFLIXID スキームで netflixId + secureNetflixId を送信 +2. **サーバー応答**: UserIdToken を発行 (有効期間 14日) +3. **以降のリクエスト (348回観測)**: key 18 (`useridtoken`) で認証。userauthdata は送信しない + +353件の MSL ヘッダーのうち userauthdata を含むのは **1件のみ** (0.3%)。 +apiRequest の第5引数 (`UserAuthenticationData`) は常に null — MslControl 層が内部で設定する。 + +--- + +## キャプチャ実データ + +### Phase 1: 初回ハンドシェイク (Widevine 鍵交換) + +ソース: `crypto/0002_msl.widevine.encrypt.json` (2026-03-13T09:00:02.108Z) + +userauthdata なし、useridtoken なし。Widevine 鍵交換のみ。 + +``` +MessageHeader: + key 17 (mastertoken): list (len=1) + [0].15 (tokendata): + [0].16 (signature): + key 19 (renewable): false + key 20 (sender): "" + key 21 (handshake): true + key 22 (messageid): 5608206180487888 + key 24 (timestamp): 1773392401 + key 36 (capabilities): + 37 (compressionalgos): [] + 38 (languages): ["GZIP", "LZW"] + 39 (encoders): ["CBOR"] + maxpayloadchunksize: -1 + key 40 (peer): false + key 42 (keyrequestdata): list (len=1) + [0].30 (scheme): "WIDEVINE" + [0].31 (keydata): {duid: , 50 (cdmsg): } +``` + +### Phase 2: userauthdata 送信 (NETFLIXID 認証, 1回のみ) + +ソース: `android14.prod.ftl.netflix.com/0052_msl.widevine.encrypt.json` (2026-03-13T09:02:28.168Z) + +``` +MessageHeader: + key 17 (mastertoken): list (len=1) + key 19 (renewable): false + key 20 (sender): "" + key 21 (handshake): true + key 22 (messageid): 2491352839960294 + key 24 (timestamp): 1773392548 + key 36 (capabilities): {37: [], 38: ["GZIP","LZW"], 39: ["CBOR"], maxpayloadchunksize: -1} + key 40 (peer): true + key 41 (nonreplayableid): 10 + key 42 (keyrequestdata): list (len=1) + [0].30 (scheme): "WIDEVINE" + key 47 (userauthdata): + 30 (scheme): "NETFLIXID" + 35 (authdata): + 56 (netflixid): "v=3&mac=AQEAEQABABQxZyPp7r91RNj4pmPTIatYcL3jjIUVbvQ.&dt=1773392547767" + 60 (securenetflixid): "v=3&ct=BgjHlOvcAxLcA5Nned7dv-Iq5m2f7a5MNrKy8VAkRdBzSXaHjTsiFmEI + -etnBrrNnCGzam9tRfUqnfVpjLaK3eFk79_6Jcsspf05U_HMC9CnJ3vy0tUhQatCl9ma2mrGqIWMm6fBWX11 + CofiJwnM-OpAzHO3fO5AJwHFSMwa7pCucoXbyjee_5tLcNdykJrEGPRnjJRc6DPfaYAJN4sDgCJptsO1Ssfw + A0CTujgVhkf4r7S-HloF7BOktKT_dwzw2ULtuZXg5uC0XySU6Phk9y-jRjqQ9LvpVD-E9mXrKMAH23yxAI5 + Ku-YRnmTOEMhk92gJBVTwnHJAe13NihHhF-aHxXDfBcMkWPHDT6-raPJ3HClitacWIoWQ2AH0q5WRrm1XBMg + A4KuNIepWOfJV9AGRVAfGoKpxbLjU6ajSx1KiD9o5KkaZ1yvEvlxtAxbISivsKhVocRN7Lt12W_9jsuUZbL-f + urw4jGezz5lHUNLSkh4suuiVYdAMMkhyPnbC1pTKE-fRxd2stJ0uuOAord3hrUbKyDK5g_nbvRSXZFW1sLb22 + 691pj5FnCxhkJquZtoW67Iwa7hVo9Jg5cUVSNvbuVLC0hmRotilPJujiaLz-XQty0SUaWv-WArtj-aEXjYKa3 + DuGAYiDgoMGowgnSHIWWhHgSZz&pg=ZEULH5S2GNGCRAABCSG6J2EGGA&ch=AQEAEAABABTp79nN9l_2MuRh + qTXl0-SjAqcm83QU8vw." +``` + +#### netflixid デコード + +``` +v=3 +mac=AQEAEQABABQxZyPp7r91RNj4pmPTIatYcL3jjIUVbvQ. +dt=1773392547767 +``` + +#### securenetflixid デコード + +``` +v=3 +ct=BgjHlOvcAxLcA5Nned7dv-Iq5m2f7a5MNrKy8VAk... (672文字, 暗号化認証トークン) +pg=ZEULH5S2GNGCRAABCSG6J2EGGA (プロファイル GUID) +ch=AQEAEAABABTp79nN9l_2MuRhqTXl0-SjAqcm83QU8vw. (チャネル HMAC) +``` + +### Phase 3: 通常リクエスト (UserIdToken 認証, 348回) + +ソース: `android14.prod.cloud.netflix.com/0002_msl.widevine.encrypt.json` (2026-03-13T09:00:02.415Z) + +``` +MessageHeader: + key 17 (mastertoken): list (len=2) + key 18 (useridtoken): + 15 (tokendata): + 16 (signature): 01010081000101204005d2ae93a88d111b34aecb30b911f634e542d41c97f8f8a305af3850d3e9a22de3595d + key 19 (renewable): false + key 20 (sender): "" + key 21 (handshake): false + key 22 (messageid): 491822966207012 + key 24 (timestamp): 1773392402 + key 36 (capabilities): {37: [], 38: ["GZIP","LZW"], 39: ["CBOR"], maxpayloadchunksize: -1} + key 40 (peer): false +``` + +#### UserIdToken tokendata (CBOR decoded) + +``` + 11 (issuedate): 1773420422 (2026-03-13T16:47:02+00:00) + 12 (mtserialnum): 8079134327147185 + 13 (expiration): 1774601222 (2026-03-27T08:47:02+00:00) ← 発行から14日後 + 25 (serialnumber): 5970945423176003 + 29 (userdata): (暗号化済み) + profileid: "ZEULH5S2GNGCRAABCSG6J2EGGA" +``` + +--- + +## ログインフロー キャプチャ実データ + +セッション `android_20260313` で実際に観測されたログイン〜認証完了までの全リクエスト。 +ドメインが `android.prod.cloud.netflix.com` (MSL v14 ではない旧ドメイン) の場合は `userId: null` で未認証状態。 + +### Step 1: RenewSSOToken (既存セッション更新の試行) + +``` +ts: 2026-03-13T09:00:02.245Z +domain: android14.prod.cloud.netflix.com +userId: ZEULH5S2GNGCRAABCSG6J2EGGA +userauthdata: null +``` + +```json +{ + "operationName": "RenewSSOToken", + "variables": { + "ssoToken": "BgiHtuvcAxL4AWLUqSE7W14gsbHZBDGMle4StHvRnr70_-VHofvb_wqAIEYDS3SmOscABWcD-0O-ibsEZPZYT3R7kUeCsJ14vV6crTlZY5DozcjvtZbtjykI8sqaQzMNtrOq1Af9lZemVLhi5FqnmNp7uy2RpwwiCqojwvVWzmKRzeOKStqUHDSIdzAofrErd6kAsfoJZleWTV87eSx2gyAa9mIauY_A51-rE2lOaTj19CeVaTeqSrWZMqWzuZ0OaYfqVZkuUf7CZ1bW1NTtlxS3mo17dmxIhpIfX8CFf26dp1Ggui93k_2dNbOF4_8e_whkxNBpTNh1RMmalk11gJocGAYiDgoMYoDsMZdYw3vL-Wdq" + }, + "extensions": { + "persistedQuery": { + "version": 102, + "id": "a4d00303-b02d-47c9-a53f-776b6a63b001" + } + } +} +``` + +### Step 2: InterstitialHook (ログイン画面初期化) + +``` +ts: 2026-03-13T09:01:29.542Z +domain: android.prod.cloud.netflix.com +userId: null ← 未認証 +userauthdata: null +``` + +```json +{ + "operationName": "InterstitialHook", + "variables": { + "flowName": "loginMobile", + "format": "HTML", + "resolutionMode": "ANDROID_XHDPI", + "imageFormat": "PNG", + "parameters": null, + "commonParameters": { + "isConsumptionOnly": true, + "isNetflixPreloaded": false, + "channelId": "", + "androidInstallType": "regular" + } + }, + "extensions": { + "persistedQuery": { + "version": 102, + "id": "bb592c79-c026-44e3-a989-ddba298d5eaf" + } + } +} +``` + +### Step 3: InterstitialScreenUpdate — メールアドレス送信 + reCAPTCHA + +``` +ts: 2026-03-13T09:02:12.214Z +domain: android.prod.cloud.netflix.com +userId: null ← 未認証 +userauthdata: null +``` + +```json +{ + "operationName": "InterstitialScreenUpdate", + "variables": { + "serverState": { + "realm": "growth", + "name": "IDENTIFICATION", + "clcsSessionId": "74a682e9-d991-4d03-a2e4-aa130cdd1623", + "sessionContext": { + "session-breadcrumbs": { "funnel_name": "loginMobile" }, + "MobileLoginSessionContext": {} + }, + "hellfireSessionId": "d1141445-134a-37ae-b91f-efe6902155e1" + }, + "serverScreenUpdate": { + "realm": "custom", + "name": "growthProcessLogin", + "metadata": { + "recaptchaSiteKey": "6LeWeOoUAAAAAJB9vW-OBEYmBwbF9R7PILe6U_ML" + }, + "loggingAction": "Submitted", + "loggingCommand": "SubmitCommand" + }, + "inputFields": [ + { "name": "userLoginId", "value": { "stringValue": "lemonandchan+5@gmail.com" } }, + { "name": "countryCode", "value": { "stringValue": "81" } }, + { "name": "countryIsoCode", "value": { "stringValue": "JP" } }, + { "name": "password", "value": { "stringValue": "" } }, + { "name": "recaptchaResponseTime", "value": { "intValue": 1231 } }, + { "name": "recaptchaResponseToken","value": { "stringValue": "" } } + ] + }, + "extensions": { + "persistedQuery": { + "version": 102, + "id": "d1a0c5d5-2c35-4b98-8b9c-11d8e7706148" + } + } +} +``` + +> **注**: `password` は空文字列。パスワードレスログイン (MFA OTP) が使用されている。 +> `recaptchaSiteKey`: `6LeWeOoUAAAAAJB9vW-OBEYmBwbF9R7PILe6U_ML` (Google reCAPTCHA v3) + +### Step 4: InterstitialScreenUpdate — MFA OTP 送信 + +``` +ts: 2026-03-13T09:02:27.251Z +domain: android.prod.cloud.netflix.com +userId: null ← まだ未認証 +userauthdata: null +``` + +```json +{ + "operationName": "InterstitialScreenUpdate", + "variables": { + "serverState": { + "realm": "growth", + "name": "MFA_COLLECT_OTP_EMAIL_INPUT", + "clcsSessionId": "74a682e9-d991-4d03-a2e4-aa130cdd1623", + "sessionContext": { + "session-breadcrumbs": { "funnel_name": "loginMobile" }, + "MobileLoginSessionContext": {} + }, + "hellfireSessionId": "d1141445-134a-37ae-b91f-efe6902155e1" + }, + "serverScreenUpdate": { + "realm": "custom", + "name": "growthVerifyMfaChallenge", + "metadata": { "validateLength": 4 }, + "loggingAction": "Submitted", + "loggingCommand": "SubmitCommand" + }, + "inputFields": [ + { "name": "challengeOtp", "value": { "stringValue": "8104" } } + ] + }, + "extensions": { + "persistedQuery": { + "version": 102, + "id": "d1a0c5d5-2c35-4b98-8b9c-11d8e7706148" + } + } +} +``` + +> **注**: MFA OTP は 4 桁 (`validateLength: 4`)。メールに送信された OTP `8104` を入力。 + +### Step 5: InterstitialSendFeedback — ログイン完了、プロフィール画面へ遷移 + +``` +ts: 2026-03-13T09:02:30.453Z +domain: android.prod.cloud.netflix.com +userId: ZEULH5S2GNGCRAABCSG6J2EGGA ← 認証完了 +userauthdata: null +``` + +```json +{ + "operationName": "InterstitialSendFeedback", + "variables": { + "serverState": { + "realm": "growth", + "name": "MFA_COLLECT_OTP_EMAIL_INPUT", + "clcsSessionId": "74a682e9-d991-4d03-a2e4-aa130cdd1623" + }, + "serverFeedback": { + "name": "system.inAppNavigation", + "metadata": { "loggingCommand": "Navigating to /profiles" } + }, + "inputFields": [] + }, + "extensions": { + "persistedQuery": { + "version": 102, + "id": "4718d209-37d8-4b43-ae43-858cd07c6c0b" + } + } +} +``` + +### Step 6: AccountQuery (認証後, userId 確定) + +``` +ts: 2026-03-13T09:02:29.644Z +domain: android14.prod.cloud.netflix.com +userId: TEMP_PROFILE_ID ← 一時プロファイル + +ts: 2026-03-13T09:02:31.123Z +domain: android14.prod.cloud.netflix.com +userId: ZEULH5S2GNGCRAABCSG6J2EGGA ← 確定プロファイル +``` + +### Step 7: userauthdata (NETFLIXID) 送信 — MSL 層で初回ユーザー認証 + +``` +ts: 2026-03-13T09:02:28.168Z ← Step 4〜5 の間 +domain: android14.prod.ftl.netflix.com +``` + +→ Phase 2 のデータ参照 (前述)。ログイン完了直後に MslControl が netflixId + secureNetflixId を MSL MessageHeader に埋め込み。 + +### ログインフロー時系列まとめ + +| 時刻 | ステップ | userId | ドメイン | 内容 | +|---|---|---|---|---| +| 09:00:02 | RenewSSOToken | `ZEULH5S..` | android14.prod.cloud | 既存セッションの SSO トークン更新 | +| 09:01:29 | InterstitialHook | null | android.prod.cloud | ログイン画面初期化 (`loginMobile`) | +| 09:02:12 | InterstitialScreenUpdate | null | android.prod.cloud | メールアドレス送信 + reCAPTCHA (`IDENTIFICATION`) | +| 09:02:27 | InterstitialScreenUpdate | null | android.prod.cloud | MFA OTP `8104` 送信 (`MFA_COLLECT_OTP_EMAIL_INPUT`) | +| 09:02:28 | **MSL userauthdata** | — | android14.prod.ftl | **NETFLIXID 認証** (netflixId + secureNetflixId) | +| 09:02:29 | AccountQuery | `TEMP_PROFILE_ID` | android14.prod.cloud | 一時プロファイルでアカウント照会 | +| 09:02:30 | InterstitialSendFeedback | `ZEULH5S..` | android.prod.cloud | ログイン完了 → `/profiles` 遷移 | +| 09:02:31 | AccountQuery | `ZEULH5S..` | android14.prod.cloud | 確定プロファイルでアカウント照会 | + +--- + +## 実際の Cookie とリクエストヘッダー + +2026-03-13 キャプチャから取得した実データ。 + +### Cookie 一覧 + +3 つの Cookie が送信される: + +| Cookie 名 | 説明 | サンプル値 | +|---|---|---| +| `nfvdid` | Netflix デバイス ID | `BQFmAAEBEEPj84LzHGpQ_ldxaVuQv8tg...` (Base64, 約130文字) | +| `NetflixId` | メイン認証トークン | `v=3&ct=BgjHlOvc...&pg=ZEULH5S2GNG...&ch=AQEAEAABABTp79nN...` (約600文字) | +| `SecureNetflixId` | セキュア認証トークン (HMAC) | `v=3&mac=AQEAEQABABSdkuDd...&dt=1773391620952` | + +### NetflixId の構造 + +``` +v=3 +ct=BgjHlOvcAxLcA6sY6f_QqKsJWi9efmzU7gW0d6XdGtEbMELNpOs-Ws2jKWjrRgmY8LTraOV1L5gpMfa5YJ63VaUYZrzppkA0wm-r_7A_XkrygJ4mEYMVax3K5POwTnVKZK-k2h5ITFTGYp3sHXwBQy3BbuBTJ8rD4nQOXGgRbm3RilTMKcOCHYyahc4YSmE-E3CPdoASBsLzpHAszy7XN8Mwc4iGr198Hac49DdcXpLhUaP6LGm8j9_3nI0R2gg8B-ntAZIKumUrbQXSsb_zMv71GxZYcKnjNVfwFAKppf3gydKKhcvzuFKw7jZvebCSYp83l56MRB88yd_vUjmXsDUdZS1AXjYMk9zVfHoARiF-2-c0FSfS3zb2SQWQkUcgTR_SabzN5u6_w6zXgffoYlVJDPsmy8Z9pDGYFzOGL3Xqc_UNIz4KdEPCDkSGmQC9nhVOJXwXYb-LBhVC7qE1Cz1vpZ98mYEwE7LomPN3-34eGIWBeeMR8E4VXfQJnL4iBnO6hBfRmy8xNRgDA7VlAjJiYYolnK_x4_gTu2-x7A5AK1WYqobiCkcOClMQIioAC8ZlH17Wf_eN2lFfOEP6G50KJpYPhUezIpPzfV5osQT9Q0AsfUrzRF3MWXDM9ukNDmHiYfN0GAYiDgoMSvU3vVSCd907UQKa +pg=ZEULH5S2GNGCRAABCSG6J2EGGA +ch=AQEAEAABABTp79nN9l_2MuRhqTXl0-SjAqcm83QU8vw. +``` + +- `v`: バージョン (3) +- `ct`: 暗号化トークン本体 (Base64url) +- `pg`: プロファイル GUID +- `ch`: チャンネル/チェックサム + +### SecureNetflixId の構造 + +``` +v=3 +mac=AQEAEQABABSdkuDdxX0gGJU2IJOTCYiVHmFkqRtHQj0. +dt=1773391620952 +``` + +- `v`: バージョン (3) +- `mac`: HMAC 署名 (Base64url) +- `dt`: タイムスタンプ (epoch ms) + +--- + +## cURL 形式リクエスト例 + +### 共通ヘッダー + +``` +User-Agent: com.netflix.mediaclient/63928 (Linux; U; Android 14; en; Pixel 4a (5G); Build/UP1A.231005.007) +X-Netflix-ProxyEsn: NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-0202Q7INAHS2TKI5GTQESPDEHTFK7MG1BKUU7QAPUQP2QMI641A8HN08CE40C5H2K4J15NCLBC5DGJI0M03TMV0VGS1ER8VACIG0257E +``` + +### 1. Push 通知 WebSocket 接続 + +```bash +curl -v \ + -H 'Origin: http://www.netflix.com' \ + -H 'User-Agent: com.netflix.mediaclient/63928 (Linux; U; Android 14; en; Pixel 4a (5G); Build/UP1A.231005.007)' \ + -H 'X-Netflix-ProxyEsn: NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-0202Q7INAHS2TKI5GTQESPDEHTFK7MG1BKUU7QAPUQP2QMI641A8HN08CE40C5H2K4J15NCLBC5DGJI0M03TMV0VGS1ER8VACIG0257E' \ + -H 'Upgrade: websocket' \ + -H 'Connection: Upgrade' \ + -H 'Sec-WebSocket-Version: 13' \ + -H 'Sec-WebSocket-Extensions: permessage-deflate' \ + -b 'nfvdid=BQFmAAEBEEPj84LzHGpQ_ldxaVuQv8tgIBE9VAe3w-WeF5En4w5goMB6eLYVXqxblfzh23QC62wkeecrOuKtsfIRNC5GkWZ80HdbCeAoFLJ8LL5stGc-h87ykqdvoTT1Vg5jhdcHity8mbE5rciYfRNlegiejB_j; NetflixId=v%3D3%26ct%3DBgjHlOvcAxLcA6sY6f_QqKsJWi9efmzU7gW0d6XdGtEbMELNpOs-Ws2jKWjrRgmY8LTraOV1L5gpMfa5YJ63VaUYZrzppkA0wm-r_7A_XkrygJ4mEYMVax3K5POwTnVKZK-k2h5ITFTGYp3sHXwBQy3BbuBTJ8rD4nQOXGgRbm3RilTMKcOCHYyahc4YSmE-E3CPdoASBsLzpHAszy7XN8Mwc4iGr198Hac49DdcXpLhUaP6LGm8j9_3nI0R2gg8B-ntAZIKumUrbQXSsb_zMv71GxZYcKnjNVfwFAKppf3gydKKhcvzuFKw7jZvebCSYp83l56MRB88yd_vUjmXsDUdZS1AXjYMk9zVfHoARiF-2-c0FSfS3zb2SQWQkUcgTR_SabzN5u6_w6zXgffoYlVJDPsmy8Z9pDGYFzOGL3Xqc_UNIz4KdEPCDkSGmQC9nhVOJXwXYb-LBhVC7qE1Cz1vpZ98mYEwE7LomPN3-34eGIWBeeMR8E4VXfQJnL4iBnO6hBfRmy8xNRgDA7VlAjJiYYolnK_x4_gTu2-x7A5AK1WYqobiCkcOClMQIioAC8ZlH17Wf_eN2lFfOEP6G50KJpYPhUezIpPzfV5osQT9Q0AsfUrzRF3MWXDM9ukNDmHiYfN0GAYiDgoMSvU3vVSCd907UQKa%26pg%3DZEULH5S2GNGCRAABCSG6J2EGGA%26ch%3DAQEAEAABABTp79nN9l_2MuRhqTXl0-SjAqcm83QU8vw.; SecureNetflixId=v%3D3%26mac%3DAQEAEQABABSdkuDdxX0gGJU2IJOTCYiVHmFkqRtHQj0.%26dt%3D1773391620952' \ + 'https://android14.push.prod.netflix.com/ws' +``` + +### 2. PlayExchange WebSocket 接続 + +```bash +curl -v \ + -H 'Origin: http://www.netflix.com' \ + -H 'User-Agent: com.netflix.mediaclient/63928 (Linux; U; Android 14; en; Pixel 4a (5G); Build/UP1A.231005.007)' \ + -H 'X-Netflix-ProxyEsn: NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-0202Q7INAHS2TKI5GTQESPDEHTFK7MG1BKUU7QAPUQP2QMI641A8HN08CE40C5H2K4J15NCLBC5DGJI0M03TMV0VGS1ER8VACIG0257E' \ + -H 'x-netflix.socketrouter.schema.version: 2' \ + -H 'X-Netflix.Request.Client.Context: {"appstate":"foreground"}' \ + -H 'x-netflix.socketrouter.group.name: Test80913.Cell2|Test80897.Cell2|Test80905.Cell2' \ + -H 'Upgrade: websocket' \ + -H 'Connection: Upgrade' \ + -H 'Sec-WebSocket-Version: 13' \ + -H 'Sec-WebSocket-Extensions: permessage-deflate' \ + -b 'nfvdid=BQFmAAEBEEPj84LzHGpQ_ldxaVuQv8tgIBE9VAe3w-WeF5En4w5goMB6eLYVXqxblfzh23QC62wkeecrOuKtsfIRNC5GkWZ80HdbCeAoFLJ8LL5stGc-h87ykqdvoTT1Vg5jhdcHity8mbE5rciYfRNlegiejB_j; NetflixId=v%3D3%26ct%3DBgjHlOvc...%26pg%3DZEULH5S2GNGCRAABCSG6J2EGGA%26ch%3DAQEAEAABABTp79nN9l_2MuRhqTXl0-SjAqcm83QU8vw.; SecureNetflixId=v%3D3%26mac%3DAQEAEQABABSdkuDdxX0gGJU2IJOTCYiVHmFkqRtHQj0.%26dt%3D1773391620952' \ + 'https://android14.ws.prod.cloud.netflix.com/playexchange' +``` + +### 3. MSL API リクエスト (Samurai API) + +MSL API は WebSocket 上の MSL プロトコルで送信される。HTTP レベルでは上記 WebSocket 接続のみ。 +MSL メッセージ内部で以下のエンドポイントにルーティングされる: + +``` +https://android14.prod.ftl.netflix.com/nq/androidui/samurai/~9.0.0/api +https://android14.prod.ftl.netflix.com/nq/androidui/samurai/v1/config +https://android14.prod.ftl.netflix.com/playapi/android/event/1 +https://android14.prod.cloud.netflix.com/graphql +https://android.prod.cloud.netflix.com/graphql +https://android14.logs.netflix.com/log/android/logblob/1 +``` + +> **注意**: MSL メッセージボディは CBOR エンコード → Widevine CryptoContext で暗号化されるため、 +> cURL で直接再現するには暗号化済みバイナリを POST する必要がある。 +> Cookie は HTTP トランスポート層で送信され、MSL レイヤーの `userauthdata` (CBOR key 47) にも +> 同じ `netflixId`/`secureNetflixId` が埋め込まれる(二重送信)。 + +### ESN (デバイス識別子) の構造 + +``` +NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-0202Q7INAHS2TKI5GTQESPDEHTFK7MG1BKUU7QAPUQP2QMI641A8HN08CE40C5H2K4J15NCLBC5DGJI0M03TMV0VGS1ER8VACIG0257E +``` + +| セグメント | 値 | 意味 | +|---|---|---| +| プラットフォーム | `NFANDROID1` | Netflix Android | +| セキュリティ | `PXA-P` | ProGuard eXtended Authentication - Production | +| DRM レベル | `L3` | Widevine Level 3 | +| デバイスモデル | `GOOGLPIXEL=4A==5G=` | Google Pixel 4a (5G) | +| Widevine System ID | `22594` | CDM System ID | +| デバイス固有 ID | `0202Q7INA...0257E` | ハードウェア固有識別子 | + +--- + +## ESN 送信箇所の全体マップ + +2026-03-13 キャプチャデータの分析結果。ESN は複数の場所・形式で送信される。 + +### 2 種類の ESN + +同一デバイスに対して **2 種類の ESN** が使い分けられている: + +| 種別 | プレフィックス | 用途 | デバイス固有ID | +|---|---|---|---| +| **PXA (ProxyEsn)** | `NFANDROID1-PXA-P-L3-` | HTTP ヘッダー (`X-Netflix-ProxyEsn`) | `0202Q7INA...0257E` | +| **PRV (Private)** | `NFANDROID1-PRV-P-L3-` | ライセンス要求、DRM、再生イベント | `3E369F1C...E371` | + +- `PXA` = ProGuard eXtended Authentication。WebSocket 接続時のデバイス識別に使用。 +- `PRV` = Private。DRM ライセンスおよびコンテンツ再生関連で使用。 +- デバイスモデル部分 (`GOOGLPIXEL=4A==5G=`) と Widevine System ID (`22594`) は共通。 +- デバイス固有 ID 部分は種別ごとに異なる。 + +### ESN が送信される全箇所 + +| # | 送信箇所 | ESN 種別 | レイヤー | フック可能性 | +|---|---|---|---|---| +| 1 | `X-Netflix-ProxyEsn` HTTP ヘッダー | PXA | HTTP (OkHttp) | OkHttp `Request.newBuilder()` で差し替え | +| 2 | `/license` URL クエリパラメータ `esn=` | PRV (完全) | MSL API パラメータ | `apiRequest` body 書き換え | +| 3 | `/events` URL クエリパラメータ `esn=` | PRV (完全) | MSL API パラメータ | `apiRequest` body 書き換え | +| 4 | `challengeBase64` (Widevine CDM protobuf) | PRV (完全) | DRM (MediaDrm) | `MediaDrm.getProvisionRequest` / `setPropertyString` | +| 5 | `logblob` ボディ内 | 短縮 (`NFANDROID1-GOOGLPIXEL=...`) | MSL API パラメータ | `apiRequest` body 書き換え | +| 6 | MSL MessageHeader `sender` (CBOR key 20) | **空文字列** | MSL CBOR | 使われていない(書き換え不要) | + +### 各箇所の詳細 + +#### 1. X-Netflix-ProxyEsn (HTTP ヘッダー) + +WebSocket 接続 (`/ws`, `/playexchange`) 時に送信される。 + +``` +X-Netflix-ProxyEsn: NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-0202Q7INAHS2TKI5GTQESPDEHTFK7MG1BKUU7QAPUQP2QMI641A8HN08CE40C5H2K4J15NCLBC5DGJI0M03TMV0VGS1ER8VACIG0257E +``` + +**フック方法**: OkHttp `RealCall.getResponseWithInterceptorChain` 内で `request.newBuilder().removeHeader().addHeader()` で差し替え。 + +#### 2-3. /license, /events URL クエリパラメータ + +MSL API ペイロード内の URL パスに `esn=` として埋め込まれる。 + +``` +/license?licenseType=standard&playbackContextId=E3-...&esn=NFANDROID1-PRV-P-L3-GOOGLPIXEL%3D4A%3D%3D5G%3D-22594-3E369F1C9B189ED664E13DEEEE9BACB67684B4FE6283537D1A67810BFE73E371&drmContextId=2596051 +``` + +**フック方法**: `ApiHandlerImpl.apiRequest` の body (arguments[1]) を書き換え。または ESN 提供元をフックすればアプリが自動的に新 ESN で URL を組み立てる。 + +#### 4. challengeBase64 (Widevine CDM protobuf) + +DRM ライセンス要求の `challengeBase64` フィールドに含まれる Widevine CDM protobuf 内に ESN が埋め込まれている。 + +```json +{ + "challengeBase64_contents": { + "device_certificate": { + "esn": "NFANDROID1-PRV-P-L3-GOOGLPIXEL=4A==5G=-22594-3E369F1C9B189ED664E13DEEEE9BACB67684B4FE6283537D1A67810BFE73E371", + "movieid": "81756595", + "issuetime": 1773373148, + "salt": "3598263455819263519576642966605" + }, + "oem_crypto_build_information": "OEMCrypto Level3 Code May 20 2022 21:36:54", + "widevine_cdm_version": "17.0.0", + "device_name": "bramble", + "architecture_name": "arm64-v8a" + } +} +``` + +この protobuf は `MediaDrm` CDM が生成するため、**直接書き換えが最も難しい箇所**。 + +**フック方法 (候補)**: +- `MediaDrm.setPropertyString("esn", ...)` をフックして差し替え — CDM が protobuf 生成前に ESN を取得する段階で介入 +- `MediaDrm.getProvisionRequest()` の戻り値 (byte[]) 内を文字列置換 — protobuf 構造が壊れるリスクあり +- CDM より上流の ESN 提供クラス (SharedPreferences / DeviceInfo) をフック — CDM に渡る前に差し替えるため protobuf 構造は保持される + +> **注意**: `challengeBase64` 内の ESN は Widevine サーバー側で検証される可能性がある。 +> ESN を差し替えても CDM の `device_certificate` 署名と不整合が生じると、 +> ライセンス取得がエラーになる場合がある。 + +#### 5. logblob ボディ (テレメトリ) + +テレメトリログ送信時に短縮 ESN が含まれる: + +``` +NFANDROID1-GOOGLPIXEL=4A==5G=S-3269A5B59F399B066558C80DCB36AE97F9A8B477F8854DFED2C14C8453D6F556 +``` + +セキュリティ種別 (`PXA`/`PRV`) を含まない短縮形式。デバイス固有 ID も PXA/PRV とは異なる第3の値。 + +#### 6. MSL MessageHeader sender (CBOR key 20) + +キャプチャした全 353 ヘッダーで **sender は空文字列 `""`** だった。 +Android 版では MSL MessageHeader の sender フィールドは使われておらず、ESN は上記 1-5 の経路で送信される。 + +### ESN 書き換え戦略まとめ + +``` +最上流 (推奨) + ├── SharedPreferences / DeviceInfo → ESN 文字列の保持元 + │ └── ここを書き換えれば #1, #2, #3, #5 は自動的に反映 + │ + ├── MediaDrm.setPropertyString("esn") → CDM への ESN 設定 + │ └── ここを書き換えれば #4 (challengeBase64) にも反映 + │ +最下流 (個別対応) + ├── OkHttp Request.newBuilder() → #1 のみ + ├── apiRequest body replace → #2, #3, #5 + └── protobuf byte[] replace → #4 (構造破壊リスク) +``` + +**理想的なアプローチ**: ESN の提供元 (SharedPreferences / EsnManager 相当クラス) と `MediaDrm.setPropertyString` の 2 箇所をフックすれば、全箇所に波及する。 diff --git a/docs/instructions/ios/license_data.md b/docs/instructions/ios/license_data.md new file mode 100644 index 0000000..b2dfef8 --- /dev/null +++ b/docs/instructions/ios/license_data.md @@ -0,0 +1,383 @@ +# Netflix FairPlay DRM License Exchange - Reference Data + +> iOS アプリ (CLIENT-15.48.1) から MSL 経由で取得した実データ。 +> 2026-03-12 にキャプチャ。デバイス: iPhone 7 (iPhone9,1) / iOS 15.8.3 + +--- + +## 概要 + +Netflix iOS アプリは **FairPlay Streaming (FPS)** DRM を使用してコンテンツを保護している。 +ライセンス交換は MSL (Message Security Layer) の上で行われ、以下のフローで進む: + +1. **Manifest 取得** → 再生可能なストリーム一覧 + `playbackContextId` を取得 +2. **License Request (standard)** → 初回の FairPlay SPC (Server Playback Context) を送信 +3. **License Response** → CKC (Content Key Context) を受信、復号鍵を取得 +4. **License Request (limited)** → 再生中にビットレート切り替え等で追加ライセンスを取得 +5. **Release License** → 再生終了時にライセンスを解放 + +ライセンスリクエスト/レスポンスはすべて **AES-128-CBC + HMAC-SHA256** で暗号化された MSL ペイロードとして送信される。以下のデータはアプリ内部で MSL 暗号化される前の平文。 + +--- + +## エンドポイント + +``` +POST /nq/iosplatform/pbo_license/~1.0.0/router +``` + +MSL URL としては: +``` +/license?licenseType={standard|limited}&playbackContextId={id}&esn={esn}&drmContextId={id} +``` + +### URL パラメータ + +| パラメータ | 説明 | +|---|---| +| `licenseType` | `standard` (初回) or `limited` (追加/ビットレート変更時の LDL) | +| `playbackContextId` | Manifest レスポンスで取得した再生セッション ID (Base64url-like) | +| `esn` | Netflix デバイス ESN。`NFAPPL-02-{MODEL}-{HASH}` 形式 | +| `drmContextId` | DRM コンテキスト ID (数値) | + +--- + +## 1. Standard License Request (初回) + +再生開始時に送信される。Manifest 取得直後、2つの FairPlay SPC チャレンジを含む。 + +```json +{ + "mslTimeout": 19, + "preferredlanguages": { + "appselectedlanguages": ["en-JP", "en"], + "platformselectedlanguages": ["en-JP"] + }, + "url": "/license?licenseType=standard&playbackContextId=E3-Bgj5tevc...&esn=NFAPPL-02-IPHONE9%3D1-5CB1D229...&drmContextId=2596051", + "params": [ + { + "drmSessionId": "V:2:1;2;;ce4;-1;none;-1;", + "xid": "7118417868969003867", + "clientTime": 1773331407, + "videoTrackName": "V:2:1;2;;ce4;-1;none;-1;", + "challengeBase64": "" + } + ] +} +``` + +### params フィールド解説 + +| フィールド | 型 | 説明 | +|---|---|---| +| `drmSessionId` | string | DRM セッション識別子。`V:2:1;2;;ce4;-1;none;-1;` のようなセミコロン区切りフォーマット。ビデオトラック情報を含む | +| `xid` | string | リクエスト ID (トランザクション ID) | +| `clientTime` | number | クライアント時刻 (Unix epoch, seconds) | +| `videoTrackName` | string | `drmSessionId` と同値。再生対象のビデオトラック識別子 | +| `challengeBase64` | string | Base64エンコードされた JSON。FairPlay SPC を含む (後述) | + +### challengeBase64 のデコード結果 + +```json +{ + "CHALLENGES": [ + { + "ID": "18C92565-21D0-4AAF-93C2-A306AB1565AD", + "PAYLOAD": "" + }, + { + "ID": "EEF401EF-2D78-4065-80C8-EB607EA4279D", + "PAYLOAD": "" + } + ] +} +``` + +- **CHALLENGES**: 配列。通常2つのチャレンジを含む (ビデオ/オーディオのDRMグループに対応) +- **ID**: UUID。レスポンスの RESPONSES と対応する +- **PAYLOAD**: FairPlay SPC (Server Playback Context) バイナリ。先頭4バイトは `00000001` (magic number)。Apple CDM が生成する不透明なバイナリデータで、コンテンツキーのリクエストを含む + +--- + +## 2. Standard License Response + +```json +{ + "result": [ + { + "licenseResponseBase64": "", + "secureStopExpected": false, + "links": { + "releaseLicense": { + "rel": "releaseLicense", + "href": "/releaseLicense?drmLicenseContextId=E3-Bgj5tevc...;29701FE4-...;STANDARD;1773331408336" + } + }, + "drmGroupId": "191-192", + "licenseType": "standard", + "expiration": 1773374608348 + } + ], + "id": 1, + "common": {}, + "from": "playapi", + "serverTime": 1773331408353, + "version": 2 +} +``` + +### result フィールド解説 + +| フィールド | 型 | 説明 | +|---|---|---| +| `licenseResponseBase64` | string | Base64エンコードされた JSON。FairPlay CKC を含む (後述) | +| `secureStopExpected` | boolean | Secure Stop (再生終了通知) が必要か | +| `links.releaseLicense.href` | string | ライセンス解放用 URL。再生終了時にこのURLにリクエストを送る | +| `drmGroupId` | string | DRM グループ ID。`"191-192"` のようにハイフン区切り | +| `licenseType` | string | `"standard"` | +| `expiration` | number | ライセンス有効期限 (Unix epoch, milliseconds)。standard は約12時間 | + +### licenseResponseBase64 のデコード結果 + +```json +{ + "VERSION": 1, + "MEDIASESSIONID": "G3iR0pT5pxY=", + "RESPONSES": [ + { + "ID": "18C92565-21D0-4AAF-93C2-A306AB1565AD", + "PAYLOAD": "" + }, + { + "ID": "EEF401EF-2D78-4065-80C8-EB607EA4279D", + "PAYLOAD": "" + } + ] +} +``` + +- **VERSION**: プロトコルバージョン (常に `1`) +- **MEDIASESSIONID**: Base64エンコードされたメディアセッション識別子 +- **RESPONSES**: 配列。CHALLENGES の各 ID に対応する CKC を含む + - **ID**: Challenge の ID と一致する UUID + - **PAYLOAD**: FairPlay CKC (Content Key Context) バイナリ。Apple CDM に渡すことでコンテンツ復号鍵が得られる + +--- + +## 3. Limited Duration License (LDL) Request + +再生中のビットレート変更、チャプター切り替え時等に追加ライセンスを取得する。 +構造は Standard と同じだが `licenseType=limited`。 + +```json +{ + "mslTimeout": null, + "url": "/license?licenseType=limited&playbackContextId=E3-Bgj5tevc...&esn=NFAPPL-02-IPHONE9%3D1-...&drmContextId=2596051", + "params": [ + { + "drmSessionId": "V:2:1;2;;ce4;-1;none;-1;", + "videoTrackName": "V:2:1;2;;ce4;-1;none;-1;", + "xid": "7118765455155927307", + "clientTime": 1773331477, + "challengeBase64": "" + } + ] +} +``` + +Standard との差異: +- `mslTimeout`: `null` (タイムアウトなし) +- `licenseType`: `limited` +- `playbackContextId`: 異なるコンテキストIDの場合あり (別エピソードへの切り替え等) + +### LDL challengeBase64 デコード結果 + +```json +{ + "CHALLENGES": [ + { + "ID": "9B3BEEFC-B11A-4019-9966-F238CF4070BD", + "PAYLOAD": "" + }, + { + "ID": "A41B0EA1-EECC-483C-A79A-237E5BD2E65A", + "PAYLOAD": "" + } + ] +} +``` + +--- + +## 4. Limited Duration License Response + +```json +{ + "result": [ + { + "licenseResponseBase64": "", + "secureStopExpected": false, + "links": { + "releaseLicense": { + "rel": "releaseLicense", + "href": "/releaseLicense?drmLicenseContextId=...;LIMITED_DURATION;1773331477178" + } + }, + "drmGroupId": "191-192", + "licenseType": "limited_duration", + "expiration": 1773331537178 + } + ], + "id": 1, + "common": {}, + "from": "playapi", + "serverTime": 1773331477194, + "version": 2 +} +``` + +Standard との差異: +- `licenseType`: `"limited_duration"` +- `expiration`: 有効期限が非常に短い (約60秒)。再生中に定期的に更新される + +### LDL licenseResponseBase64 デコード結果 + +```json +{ + "VERSION": 1, + "MEDIASESSIONID": "znvDeutRb/4=", + "RESPONSES": [ + { + "ID": "9B3BEEFC-B11A-4019-9966-F238CF4070BD", + "PAYLOAD": "" + }, + { + "ID": "A41B0EA1-EECC-483C-A79A-237E5BD2E65A", + "PAYLOAD": "" + } + ] +} +``` + +--- + +## 5. Release License (再生終了) + +再生終了時、サーバーに通知する。レスポンスは空の `actions` オブジェクト: + +```json +{ + "result": [ + { + "actions": {} + } + ], + "id": 1, + "common": {}, + "from": "playapi", + "serverTime": 1773331482636, + "version": 2 +} +``` + +--- + +## 6. 再生イベントログから見るタイミング + +`startplayevents` ログの `eventlist` から、各イベントの相対タイミング (UIPresented=0 基準, ms): + +``` +PlaybackRequested : -4343ms +ManifestRequestStart : -4330ms +MovieHeaderDownloadStart: -4011ms +ManifestRequestEnd : -4049ms +PlaylistBuildStart : -3987ms +MovieHeaderDownloadEnd : -3990ms +PlaylistBuildEnd : -3930ms +LicenseChallengeRequested: -3925ms +LicenseChallengeReceived : -3925ms +SPLDLicenseRequestStart : -3876ms +FirstLDLTry : -3575ms +SPLDLicenseRequestEnd : -3594ms +Variant-A-Begin-1 : -3485ms +Variant-A-End-1 : -3481ms +V-Start-433 : -3482ms +V-Stop-433 : -3477ms +Variant-V-Begin-1 : -3482ms +Variant-V-End-1 : -3477ms +V-Start-1637 : -3290ms +V-Stop-1637 : -3285ms +Variant-V-Begin-2 : -3290ms +Variant-V-End-2 : -3285ms +UIPresented : 0ms +PlayerUIPresented : 0ms +PlaybackStarted : +169ms +``` + +注目ポイント: +- Manifest取得からLicenseChallenge生成まで約400ms +- LicenseRequest (SPLDLicenseRequestStart→End) は約280ms +- UIPresented から PlaybackStarted まで約170ms +- 全体で Playback Request → Playback Started は約4.5秒 + +### licenseAudit + +```json +{ + "licenseAudit": { + "ldl": [282], + "": [250] + } +} +``` + +- `ldl`: Limited Duration License の取得時間 (282ms) +- `""`: Standard License の取得時間 (250ms) + +--- + +## 7. drmSessionId / videoTrackName フォーマット + +``` +V:2:1;2;;ce4;-1;none;-1; +``` + +セミコロン区切り。推定される構造: +- `V:2:1` — ビデオトラック識別 (V=Video, 以降はインデックス) +- `2` — DRM グループ数 +- (空) — 予約 +- `ce4` — コーデック識別 (HEVC = ce4) +- `-1` — 品質レベル (-1 = auto) +- `none` — HDR タイプ (none = SDR) +- `-1` — 予約 + +--- + +## 8. ESN (Entertainment Service Name) フォーマット + +``` +NFAPPL-02-IPHONE9=1-5CB1D229FE1FC4DBA556753BB3D84634599DF9C15AD474BAFBFD37965D4162EC +``` + +- `NFAPPL` — Netflix Apple プラットフォーム +- `02` — DRM バージョン (FairPlay v2) +- `IPHONE9=1` — デバイスモデル (iPhone 7 = iPhone9,1、`=` は `,` のエスケープ) +- 末尾 64文字 — デバイス固有ハッシュ (SHA-256) + +--- + +## 9. データの関連性まとめ + +``` +Manifest Response + └─ playbackContextId ──→ License Request URL + └─ video_tracks / audio_tracks ──→ drmSessionId / videoTrackName + +License Request + └─ challengeBase64.CHALLENGES[].ID ──→ License Response.RESPONSES[].ID (1:1 対応) + └─ CHALLENGES[].PAYLOAD (FairPlay SPC) ──→ RESPONSES[].PAYLOAD (FairPlay CKC) + +License Response + └─ links.releaseLicense.href ──→ Release License Request URL + └─ expiration ──→ standard: ~12h, limited_duration: ~60s +``` diff --git a/docs/instructions/ios/license_exchange.json b/docs/instructions/ios/license_exchange.json new file mode 100644 index 0000000..e539f17 --- /dev/null +++ b/docs/instructions/ios/license_exchange.json @@ -0,0 +1,149 @@ +{ + "_description": "Netflix iOS FairPlay DRM license exchange data (captured 2026-03-12). Payloads are truncated for size. challengeBase64 is shown decoded.", + "standard_license_request": { + "mslTimeout": 19, + "preferredlanguages": { + "appselectedlanguages": [ + "en-JP", + "en" + ], + "platformselectedlanguages": [ + "en-JP" + ] + }, + "url": "/license?licenseType=standard&playbackContextId=E3-Bgj5tevcAxLxBEiSznO_e72JheeHLtj33gHzHYBxbZ3B_kjqYCzKGG9AbT6OAQIp4f6KS7SR8GXk0uJ1swLIBt1cEL3gsr7Hkr_gxaFc-L_sdlDzjXgjTs9JTqSmjioJKmeR2eQ6ZWciBVhlYbk4Y_MrmPA_QJtpWt_RjR-lMdS1wfwHyTQ18ZGiSM--JhlokPiE24AkaBrYL6gATrXizI0XjPoTLSLDNo0uSBWQaI47GjHeFwrsvbBufKCLci7RMakL7c5IcDa4RN11roJLekdEq3hGq2xTYwz-EXH35BlTePTvpX6ry7JjNFLEevLMku6w-_sWyOlBTIQaOsO4fKTrPEoUqGOVVWMg-k1DRIm9HUT2llWUYxNNd_Y2zVrsEio6zn-ExYDqVqwqUb-pSiVs6Zxphyuh9WYWO_b5-UlJ2J25t6zDqMMKeiNTIjK6UW-06rDtx43iWc7Xtvq876Jf7nGJzQfExqrV4chRUdHtdiMdutLXmG0XTQifKuhAyZ6gs0CatDxBmR_u-ys466diNEalshfetuEMn-M_a7Ecj7oZTJ2xiViqG9r0yJ9T22WOKJmeUkl-zUeY8UJ-exajFcr8Mg5cpXOYsbNQYeBg5XIjLnrgUX9C-e_8tU6TDhVoSSEpTuZkR6RJ-sgxz9hWr3_VYaPfH-gzRQ6G6BiZEYP3ZCASj9f2ENkpuebvnJvfPph0Fi8Cqdet_Zk89dyF9TZ7LSSpkCbReBPlZo0wB7p8avvPG5_ZQPTuZdqHrzq1FDZEMpG66Ko-iiRo5TmGxwyZov7GUxdWdbxK3xRuqabOpEv3pFce1cPP-4TXezoopLlfBhj37bAYBiIOCgymagQHhwEwkzuQOHk%3D&esn=NFAPPL-02-IPHONE9%3D1-5CB1D229FE1FC4DBA556753BB3D84634599DF9C15AD474BAFBFD37965D4162EC&drmContextId=2596051", + "params": [ + { + "drmSessionId": "V:2:1;2;;ce4;-1;none;-1;", + "xid": "7118417868969003867", + "clientTime": 1773331407, + "videoTrackName": "V:2:1;2;;ce4;-1;none;-1;", + "challengeBase64_decoded": { + "CHALLENGES": [ + { + "ID": "18C92565-21D0-4AAF-93C2-A306AB1565AD", + "PAYLOAD": "AAAAAQAAAABrGW7dT9jZ8hmmiQRP0pAQt2Qy+6xA7gZkVeYRsBC1g4Zy9KoGIScHF1e7ByeYfR0Qp/pD... (10348 chars total, ~7761 bytes)" + }, + { + "ID": "EEF401EF-2D78-4065-80C8-EB607EA4279D", + "PAYLOAD": "AAAAAQAAAABiMBWMk9SNBE6hY23sChMoZdBTn+U//x+CfmdO8ewMBUFHMNt6Gm5JzYXH3v/mD5aDSaUT... (9240 chars total, ~6930 bytes)" + } + ] + } + } + ] + }, + "standard_license_response": { + "result": [ + { + "licenseResponseBase64_decoded": { + "VERSION": 1, + "MEDIASESSIONID": "G3iR0pT5pxY=", + "RESPONSES": [ + { + "ID": "18C92565-21D0-4AAF-93C2-A306AB1565AD", + "PAYLOAD": "AAAAAQAAAAD2ZCdwGHIcWItMwCql2TvGAAAGANDx9oL5ZKFmoofWe+LJ8wyVi4GZq7xR+So/gO+C3FP/... (2088 chars total, ~1566 bytes)" + }, + { + "ID": "EEF401EF-2D78-4065-80C8-EB607EA4279D", + "PAYLOAD": "AAAAAQAAAACvhMrcN8hN2NxJJSYJI0uDAAAE8GCxS3QcIc0GDpcmCUZb3DaVSxrLHzcEwAa0oJQhLS7P... (1724 chars total, ~1293 bytes)" + } + ] + }, + "secureStopExpected": false, + "links": { + "releaseLicense": { + "rel": "releaseLicense", + "href": "/releaseLicense?drmLicenseContextId=E3-Bgj5tevcAxLxBEiSznO_e72JheeHLtj33gHzHYBxbZ3B_kjqYCzKGG9AbT6OAQIp4f6KS7SR8GXk0uJ1swLIBt1cEL3gsr7Hkr_gxaFc-L_sdlDzjXgjTs9JTqSmjioJKmeR2eQ6ZWciBVhlYbk4Y_MrmPA_QJtpWt_RjR-lMdS1wfwHyTQ18ZGiSM--JhlokPiE24AkaBrYL6gATrXizI0XjPoTLSLDNo0uSBWQaI47GjHeFwrsvbBufKCLci7RMakL7c5IcDa4RN11roJLekdEq3hGq2xTYwz-EXH35BlTePTvpX6ry7JjNFLEevLMku6w-_sWyOlBTIQaOsO4fKTrPEoUqGOVVWMg-k1DRIm9HUT2llWUYxNNd_Y2zVrsEio6zn-ExYDqVqwqUb-pSiVs6Zxphyuh9WYWO_b5-UlJ2J25t6zDqMMKeiNTIjK6UW-06rDtx43iWc7Xtvq876Jf7nGJzQfExqrV4chRUdHtdiMdutLXmG0XTQifKuhAyZ6gs0CatDxBmR_u-ys466diNEalshfetuEMn-M_a7Ecj7oZTJ2xiViqG9r0yJ9T22WOKJmeUkl-zUeY8UJ-exajFcr8Mg5cpXOYsbNQYeBg5XIjLnrgUX9C-e_8tU6TDhVoSSEpTuZkR6RJ-sgxz9hWr3_VYaPfH-gzRQ6G6BiZEYP3ZCASj9f2ENkpuebvnJvfPph0Fi8Cqdet_Zk89dyF9TZ7LSSpkCbReBPlZo0wB7p8avvPG5_ZQPTuZdqHrzq1FDZEMpG66Ko-iiRo5TmGxwyZov7GUxdWdbxK3xRuqabOpEv3pFce1cPP-4TXezoopLlfBhj37bAYBiIOCgymagQHhwEwkzuQOHk%3D;29701FE4-3CC7-4A34-8C5B-AE90C7439A47;STANDARD;1773331408336" + } + }, + "drmGroupId": "191-192", + "licenseType": "standard", + "expiration": 1773374608348 + } + ], + "id": 1, + "from": "playapi", + "serverTime": 1773331408353, + "version": 2 + }, + "limited_license_request": { + "mslTimeout": null, + "preferredlanguages": { + "appselectedlanguages": [ + "en-JP", + "en" + ], + "platformselectedlanguages": [ + "en-JP" + ] + }, + "url": "/license?licenseType=limited&playbackContextId=E3-Bgj5tevcAxLxBL5NAnkqpROV-Vv5c009gnelUKwWLsoz1xrjjCoijIahfybTJrUn2skQMT6LDT7xvyl1gZTowkKcS4pchPbAP8vpjPfCV6TGpM5GzSJmvgS8hYdU82kbP09lWDXYCPnoArh_5NLGHkxfuZYi_fWzXSdIyBPppI4fC5e8JwdhDfm4nauZMJlL8BcEOzkbv8pU1HEJ8XE0Y6AoOONLj3PUBtxIsY0rj9uB0IYHwJOXZoXw9AjB-0m1LEtF9Vf3C7ZaifLCgSrt3_SJpqVf-7P3WpOjKjduSU0OUHI2hJWhlluwlhUDu4ae59Mj9W6geIePu-1ZwCvE6HJ4vvfUUttR-fb3rlfV1bo037dpFspmeGcu-1f96mDKA3NuGipNlIObDrYRGSr8Bp9d9CNDREE7biE0R-vBhcXud621IyUW8sJo0A22tfvpA7Mo9apoJzgh1g7FhJiUyAq5HB0HunlVKNpZRWoIt95CUoz-MLlmqpzw3IAGOYGMLOq7V2pJrFoOq3IG_b7t9LQmtdbPCDKYdRB8seBxkJuxilC7tiobYRiAp8COxikTfrBtlW8J6ryggWUbr1MubvLKmpfo5WOBcl0ws_KC308K2RkGn0i0w2ISN6pkh6wXGCLtUlfVMc1SisoXBZ4iLAB1rIVcxQx0b9S4n4PHQW9dkLNL3MMpQMn6RCJuyxunk8jgYNILPQyqxsFXnXlwQTbgMdyRxls99QuATzPqHssrL7Wve6ZtQBwzPS6SwWS1g9Tn_RmhY64xBrz1OISU2vC0VlCHkJ1KVlSGxcXlalu5KaAHbjFHAOfMj2Bj3qe-_C9MVjTrr_O4jy0YBiIOCgzQOuElk9kZDQXw1Lk%3D&esn=NFAPPL-02-IPHONE9%3D1-5CB1D229FE1FC4DBA556753BB3D84634599DF9C15AD474BAFBFD37965D4162EC&drmContextId=2596051", + "params": [ + { + "drmSessionId": "V:2:1;2;;ce4;-1;none;-1;", + "xid": "7118765455155927307", + "clientTime": 1773331475, + "videoTrackName": "V:2:1;2;;ce4;-1;none;-1;", + "challengeBase64_decoded": { + "CHALLENGES": [ + { + "ID": "9B3BEEFC-B11A-4019-9966-F238CF4070BD", + "PAYLOAD": "AAAAAQAAAAC801WquE7coG9bDqj3kHbbBOewafkJv9iCYrMSV08ZPHyp462GdKj84Oe5vaz8nysV49lh... (11096 chars total, ~8322 bytes)" + }, + { + "ID": "A41B0EA1-EECC-483C-A79A-237E5BD2E65A", + "PAYLOAD": "AAAAAQAAAAAyyIOTwFbNFNvJgEuEqdxaPYLSOaIVd8ukY9+xSm/7sA2u0aRlFTa1NFB/l769l8M+r7bc... (10496 chars total, ~7872 bytes)" + } + ] + } + } + ] + }, + "limited_license_response": { + "result": [ + { + "licenseResponseBase64_decoded": { + "VERSION": 1, + "MEDIASESSIONID": "znvDeutRb/4=", + "RESPONSES": [ + { + "ID": "9B3BEEFC-B11A-4019-9966-F238CF4070BD", + "PAYLOAD": "AAAAAQAAAACicCtA7Mlwl64lM3kfCZlBAAAEcCfqSlc/hnHCVaHXdjoUk8WuMLVzG6l0HbOBg3jRwaG5... (1552 chars total, ~1164 bytes)" + }, + { + "ID": "A41B0EA1-EECC-483C-A79A-237E5BD2E65A", + "PAYLOAD": "AAAAAQAAAABANmpkGxS6jV17E1gHG2NAAAAEYNG9NfD6lWPjs9TWRWe2xf61zHkIMCmrvhbVFjpkxYoI... (1532 chars total, ~1149 bytes)" + } + ] + }, + "secureStopExpected": false, + "links": { + "releaseLicense": { + "rel": "releaseLicense", + "href": "/releaseLicense?drmLicenseContextId=E3-Bgj5tevcAxLxBL5NAnkqpROV-Vv5c009gnelUKwWLsoz1xrjjCoijIahfybTJrUn2skQMT6LDT7xvyl1gZTowkKcS4pchPbAP8vpjPfCV6TGpM5GzSJmvgS8hYdU82kbP09lWDXYCPnoArh_5NLGHkxfuZYi_fWzXSdIyBPppI4fC5e8JwdhDfm4nauZMJlL8BcEOzkbv8pU1HEJ8XE0Y6AoOONLj3PUBtxIsY0rj9uB0IYHwJOXZoXw9AjB-0m1LEtF9Vf3C7ZaifLCgSrt3_SJpqVf-7P3WpOjKjduSU0OUHI2hJWhlluwlhUDu4ae59Mj9W6geIePu-1ZwCvE6HJ4vvfUUttR-fb3rlfV1bo037dpFspmeGcu-1f96mDKA3NuGipNlIObDrYRGSr8Bp9d9CNDREE7biE0R-vBhcXud621IyUW8sJo0A22tfvpA7Mo9apoJzgh1g7FhJiUyAq5HB0HunlVKNpZRWoIt95CUoz-MLlmqpzw3IAGOYGMLOq7V2pJrFoOq3IG_b7t9LQmtdbPCDKYdRB8seBxkJuxilC7tiobYRiAp8COxikTfrBtlW8J6ryggWUbr1MubvLKmpfo5WOBcl0ws_KC308K2RkGn0i0w2ISN6pkh6wXGCLtUlfVMc1SisoXBZ4iLAB1rIVcxQx0b9S4n4PHQW9dkLNL3MMpQMn6RCJuyxunk8jgYNILPQyqxsFXnXlwQTbgMdyRxls99QuATzPqHssrL7Wve6ZtQBwzPS6SwWS1g9Tn_RmhY64xBrz1OISU2vC0VlCHkJ1KVlSGxcXlalu5KaAHbjFHAOfMj2Bj3qe-_C9MVjTrr_O4jy0YBiIOCgzQOuElk9kZDQXw1Lk%3D;29701FE4-3CC7-4A34-8C5B-AE90C7439A47;LIMITED;1773331477163" + } + }, + "drmGroupId": "191-192", + "licenseType": "limited_duration", + "expiration": 1773331537178 + } + ], + "id": 1, + "from": "playapi", + "serverTime": 1773331477180, + "version": 2 + }, + "release_license_response": { + "result": [ + { + "actions": {} + } + ], + "id": 1, + "common": {}, + "from": "playapi", + "serverTime": 1773331482636, + "version": 2 + } +} \ No newline at end of file diff --git a/docs/instructions/pxa_esn_llm.md b/docs/instructions/pxa_esn_llm.md new file mode 100644 index 0000000..0b8c770 --- /dev/null +++ b/docs/instructions/pxa_esn_llm.md @@ -0,0 +1,115 @@ +# PXA ESN — LLM 向けリファレンス + +## PXA ESN とは + +Netflix Android アプリがサーバーから取得するデバイス識別子。ローカル生成の Base ESN (PRV) に対し、サーバーが fingerprint を付与して発行する。API リクエストの `X-Netflix.esn` ヘッダーで使用される。 + +## 取得プロトコル + +- **経路:** MSL (Message Security Layer) over HTTPS +- **エンドポイント:** `/getProxyEsn` +- **取得方法:** Cookie ではなく、MSL レスポンスの JSON ボディで返される + +### リクエスト + +```json +{"url": "/getProxyEsn"} +``` + +MSL VolleyRequest のヘッダーに `router: getProxyEsn` が設定される。HTTP レベルのヘッダーは MSL 暗号化されるため外部からは見えない。 + +### レスポンス + +```json +{ + "id": 1, + "version": 2, + "serverTime": 1773478742949, + "result": { + "esn": "NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-02028KVLM5OU1MSB..." + }, + "common": {}, + "from": "playapi" +} +``` + +PXA ESN は `result.esn` に格納されている。 + +## 有効期限 + +- 設定値: `EsnHendrixConfig.refreshProxyEsnTimeInMs = 0` +- TTL が 0 のため **無期限キャッシュ** +- 一度取得したら SharedPreferences に永続化され、以下の場合のみ再取得: + - 初回インストール + - アプリデータクリア + - SharedPreferences の消失 + - `masterTokenSerialNumber` の変更 + +## fingerprint の特性 + +- サーバー側で生成され、ローカルでは再現不可 +- 同一デバイスでも取得ごとに異なる fingerprint が返される +- 実測で 3 回取得し、毎回異なることを確認 + +## ESN 構造 + +``` +NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-0202{fingerprint} +``` + +| セグメント | 値 | 説明 | +|---|---|---| +| Platform | `NFANDROID1-` | Android プレフィックス | +| Type | `PXA` | Proxy (サーバー発行) | +| Category | `P` | Phone (T=Tablet, B=TV, C=ChromeOS, E=Display) | +| Security | `L3` | Widevine Security Level | +| Model | `GOOGLPIXEL=4A==5G=` | サニタイズ済みデバイスモデル | +| systemId | `22594` | Widevine systemId | +| fingerprint | `0202...` | サーバー発行 (毎回異なる) | + +## 保存先 + +SharedPreferences に 2 つのキーで保存: + +| キー | 型 | 内容 | +|---|---|---| +| `nf_drm_esn` | String | PXA ESN 文字列 | +| `nf_drm_proxy_esn` | String (JSON) | `{"esn":"...","ts":epoch_ms,"sn":serial_number}` | + +## 使用箇所 + +| ヘッダー | 使用先 | +|---|---| +| `X-Netflix.esn` | GraphQL / Cronet リクエスト | +| `X-Netflix-ProxyEsn` | WebSocket / MSL リクエスト | + +## コードパス + +``` +WidevineEntityAuthEsnProviderImpl.c(serialNumber) -- 再取得が必要か判定 + → ProxyEsnMslRequest -- MSL リクエスト送信 + → body: {"url": "/getProxyEsn"} + → Netflix playapi サーバー + ← onSuccess(JSONObject) -- レスポンス受信 + → result.esn を抽出 + → ProxyEsn.onKnown(serialNumber, esn) -- SharedPreferences に保存 +``` + +## 関連クラス + +| クラス | ファイルパス | 役割 | +|---|---|---| +| `ProxyEsnMslRequest` | `mslagent/impl/ProxyEsnMslRequest.java` | MSL リクエスト発行・レスポンス処理 | +| `ProxyEsn` | `esn/impl/ProxyEsn.java` | キャッシュ管理・永続化 | +| `WidevineEntityAuthEsnProviderImpl` | `esn/impl/WidevineEntityAuthEsnProviderImpl.java` | ESN 統合管理 | +| `EsnHendrixConfig` (`o.fkQ`) | `o/C13212fkQ.java` | TTL 設定 (`refreshProxyEsnTimeInMs`) | + +## キャプチャ方法 + +`hook_msl.js` を使用。`ProxyEsn.$init` で expired フラグを強制 true にすることで、キャッシュの有無に関係なく `getProxyEsn` を発火させられる。 + +```bash +./run_android.sh hook_msl.js +``` + +出力イベント: `proxyEsn.forceExpired`, `proxyEsn.request`, `proxyEsn.requestHeaders`, `proxyEsn.response`, `proxyEsn.onKnown`, `proxyEsn.error` diff --git a/docs/licensedManifest/L1_licensedManifest.json b/docs/licensedManifest/L1_licensedManifest.json new file mode 100644 index 0000000..42ece53 --- /dev/null +++ b/docs/licensedManifest/L1_licensedManifest.json @@ -0,0 +1,424 @@ +{ + "event": "msl.api", + "ts": "2026-03-13T16:55:40.919Z", + "domain": "android14.prod.ftl.netflix.com", + "url": "https://android14.prod.ftl.netflix.com/nq/androidui/samurai/~9.0.0/api", + "headers": {}, + "body_size": 22633, + "userId": "ZEULH5S2GNGCRAABCSG6J2EGGA", + "userauthdata": null, + "body": { + "version": 2, + "url": "/licensedManifest", + "languages": [ + "en-JP" + ], + "common": { + "challenge": "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" + }, + "params": [ + { + "profiles": [ + "none-h264mpl30-dash", + "playready-h264mpl30-dash", + "none-h264mpl31-dash", + "playready-h264mpl31-dash", + "none-h264mpl40-dash", + "playready-h264mpl40-dash", + "hevc-hdr-main10-L30-dash-cenc-prk", + "hevc-hdr-main10-L30-dash-cenc-prk-do", + "hevc-hdr-main10-L31-dash-cenc-prk", + "hevc-hdr-main10-L31-dash-cenc-prk-do", + "hevc-hdr-main10-L40-dash-cenc-prk", + "hevc-hdr-main10-L40-dash-cenc-prk-do", + "hevc-hdr-main10-L41-dash-cenc-prk", + "hevc-hdr-main10-L41-dash-cenc-prk-do", + "hevc-hdr-main10-L30-dash-cenc-live", + "hevc-hdr-main10-L31-dash-cenc-live", + "hevc-hdr-main10-L40-dash-cenc-live", + "hevc-hdr-main10-L41-dash-cenc-live", + "iso_23001_18-dash-live", + "playready-h264hpl22-dash", + "h264hpl22-dash-playready-live", + "playready-h264hpl30-dash", + "h264hpl30-dash-playready-live", + "playready-h264hpl31-dash", + "h264hpl31-dash-playready-live", + "playready-h264hpl40-dash", + "h264hpl40-dash-playready-live", + "vp9-profile0-L21-dash-cenc", + "vp9-profile0-L30-dash-cenc", + "vp9-profile0-L31-dash-cenc", + "vp9-profile0-L40-dash-cenc", + "heaac-2-dash", + "xheaac-dash", + "imsc1.1", + "nflx-cmisc", + "BIF320" + ], + "profileGroups": [ + { + "name": "primary", + "profiles": [ + "none-h264mpl30-dash", + "playready-h264mpl30-dash", + "none-h264mpl31-dash", + "playready-h264mpl31-dash", + "none-h264mpl40-dash", + "playready-h264mpl40-dash", + "hevc-hdr-main10-L30-dash-cenc-prk", + "hevc-hdr-main10-L30-dash-cenc-prk-do", + "hevc-hdr-main10-L31-dash-cenc-prk", + "hevc-hdr-main10-L31-dash-cenc-prk-do", + "hevc-hdr-main10-L40-dash-cenc-prk", + "hevc-hdr-main10-L40-dash-cenc-prk-do", + "hevc-hdr-main10-L41-dash-cenc-prk", + "hevc-hdr-main10-L41-dash-cenc-prk-do", + "hevc-hdr-main10-L30-dash-cenc-live", + "hevc-hdr-main10-L31-dash-cenc-live", + "hevc-hdr-main10-L40-dash-cenc-live", + "hevc-hdr-main10-L41-dash-cenc-live", + "iso_23001_18-dash-live", + "playready-h264hpl22-dash", + "h264hpl22-dash-playready-live", + "playready-h264hpl30-dash", + "h264hpl30-dash-playready-live", + "playready-h264hpl31-dash", + "h264hpl31-dash-playready-live", + "playready-h264hpl40-dash", + "h264hpl40-dash-playready-live", + "vp9-profile0-L21-dash-cenc", + "vp9-profile0-L30-dash-cenc", + "vp9-profile0-L31-dash-cenc", + "vp9-profile0-L40-dash-cenc", + "heaac-2-dash", + "xheaac-dash", + "imsc1.1", + "nflx-cmisc", + "BIF320" + ] + } + ], + "cellularCap": "auto", + "manifestVersion": "v2", + "method": "licensedManifest", + "flavor": "PRE_FETCH", + "useBetterTextUrls": true, + "useHttpsStreams": true, + "drmType": "widevine", + "supportsWatermark": true, + "supportsPreReleasePin": true, + "requestEligibleABTests": true, + "supportsUnequalizedDownloadables": true, + "supportsAdBreakHydration": true, + "liveAdsCapability": "dynamic", + "netType": "wifi", + "osName": "android", + "osVersion": "34", + "application": "samurai", + "clientVersion": "9.57.0", + "uiVersion": "9.57.0", + "uiPlatform": "android", + "player": "streaming", + "hardware": "lito", + "uiContext": { + "uiFlavor": "android", + "clientAppVersion": "9.57.0", + "deviceTier": "AndroidDeviceTier", + "adCanvasUICapabilities": [ + "SlotBasedUI" + ] + }, + "challenges": { + "primary": [ + { + "challengeBase64": "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", + "drmSessionId": 1, + "clientTime": 1773420939 + } + ] + }, + "licenseType": "limited", + "contentPlaygraph": [ + "v2" + ], + "supportsAuxiliaryManifestDeduplication": true, + "viewableId": "81639724", + "liveMetadataFormat": "INDEXED_SEGMENT_TEMPLATE", + "maxSupportedLanguages": -1, + "supportsPartialHydration": true, + "prefersVerticalVideo": false, + "supportsVideoTrackSwitching": false, + "supportsNetflixMediaEvents": true + }, + { + "profiles": [ + "none-h264mpl30-dash", + "playready-h264mpl30-dash", + "none-h264mpl31-dash", + "playready-h264mpl31-dash", + "none-h264mpl40-dash", + "playready-h264mpl40-dash", + "hevc-hdr-main10-L30-dash-cenc-prk", + "hevc-hdr-main10-L30-dash-cenc-prk-do", + "hevc-hdr-main10-L31-dash-cenc-prk", + "hevc-hdr-main10-L31-dash-cenc-prk-do", + "hevc-hdr-main10-L40-dash-cenc-prk", + "hevc-hdr-main10-L40-dash-cenc-prk-do", + "hevc-hdr-main10-L41-dash-cenc-prk", + "hevc-hdr-main10-L41-dash-cenc-prk-do", + "hevc-hdr-main10-L30-dash-cenc-live", + "hevc-hdr-main10-L31-dash-cenc-live", + "hevc-hdr-main10-L40-dash-cenc-live", + "hevc-hdr-main10-L41-dash-cenc-live", + "iso_23001_18-dash-live", + "playready-h264hpl22-dash", + "h264hpl22-dash-playready-live", + "playready-h264hpl30-dash", + "h264hpl30-dash-playready-live", + "playready-h264hpl31-dash", + "h264hpl31-dash-playready-live", + "playready-h264hpl40-dash", + "h264hpl40-dash-playready-live", + "vp9-profile0-L21-dash-cenc", + "vp9-profile0-L30-dash-cenc", + "vp9-profile0-L31-dash-cenc", + "vp9-profile0-L40-dash-cenc", + "heaac-2-dash", + "xheaac-dash", + "imsc1.1", + "nflx-cmisc", + "BIF320" + ], + "profileGroups": [ + { + "name": "primary", + "profiles": [ + "none-h264mpl30-dash", + "playready-h264mpl30-dash", + "none-h264mpl31-dash", + "playready-h264mpl31-dash", + "none-h264mpl40-dash", + "playready-h264mpl40-dash", + "hevc-hdr-main10-L30-dash-cenc-prk", + "hevc-hdr-main10-L30-dash-cenc-prk-do", + "hevc-hdr-main10-L31-dash-cenc-prk", + "hevc-hdr-main10-L31-dash-cenc-prk-do", + "hevc-hdr-main10-L40-dash-cenc-prk", + "hevc-hdr-main10-L40-dash-cenc-prk-do", + "hevc-hdr-main10-L41-dash-cenc-prk", + "hevc-hdr-main10-L41-dash-cenc-prk-do", + "hevc-hdr-main10-L30-dash-cenc-live", + "hevc-hdr-main10-L31-dash-cenc-live", + "hevc-hdr-main10-L40-dash-cenc-live", + "hevc-hdr-main10-L41-dash-cenc-live", + "iso_23001_18-dash-live", + "playready-h264hpl22-dash", + "h264hpl22-dash-playready-live", + "playready-h264hpl30-dash", + "h264hpl30-dash-playready-live", + "playready-h264hpl31-dash", + "h264hpl31-dash-playready-live", + "playready-h264hpl40-dash", + "h264hpl40-dash-playready-live", + "vp9-profile0-L21-dash-cenc", + "vp9-profile0-L30-dash-cenc", + "vp9-profile0-L31-dash-cenc", + "vp9-profile0-L40-dash-cenc", + "heaac-2-dash", + "xheaac-dash", + "imsc1.1", + "nflx-cmisc", + "BIF320" + ] + } + ], + "cellularCap": "auto", + "manifestVersion": "v2", + "method": "licensedManifest", + "flavor": "PRE_FETCH", + "useBetterTextUrls": true, + "useHttpsStreams": true, + "drmType": "widevine", + "supportsWatermark": true, + "supportsPreReleasePin": true, + "requestEligibleABTests": true, + "supportsUnequalizedDownloadables": true, + "supportsAdBreakHydration": true, + "liveAdsCapability": "dynamic", + "netType": "wifi", + "osName": "android", + "osVersion": "34", + "application": "samurai", + "clientVersion": "9.57.0", + "uiVersion": "9.57.0", + "uiPlatform": "android", + "player": "streaming", + "hardware": "lito", + "uiContext": { + "uiFlavor": "android", + "clientAppVersion": "9.57.0", + "deviceTier": "AndroidDeviceTier", + "adCanvasUICapabilities": [ + "SlotBasedUI" + ] + }, + "challenges": { + "primary": [ + { + "challengeBase64": "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", + "drmSessionId": 1, + "clientTime": 1773420939 + } + ] + }, + "licenseType": "limited", + "contentPlaygraph": [ + "v2" + ], + "supportsAuxiliaryManifestDeduplication": true, + "viewableId": "81756595", + "liveMetadataFormat": "INDEXED_SEGMENT_TEMPLATE", + "maxSupportedLanguages": -1, + "supportsPartialHydration": true, + "prefersVerticalVideo": false, + "supportsVideoTrackSwitching": false, + "supportsNetflixMediaEvents": true + }, + { + "profiles": [ + "none-h264mpl30-dash", + "playready-h264mpl30-dash", + "none-h264mpl31-dash", + "playready-h264mpl31-dash", + "none-h264mpl40-dash", + "playready-h264mpl40-dash", + "hevc-hdr-main10-L30-dash-cenc-prk", + "hevc-hdr-main10-L30-dash-cenc-prk-do", + "hevc-hdr-main10-L31-dash-cenc-prk", + "hevc-hdr-main10-L31-dash-cenc-prk-do", + "hevc-hdr-main10-L40-dash-cenc-prk", + "hevc-hdr-main10-L40-dash-cenc-prk-do", + "hevc-hdr-main10-L41-dash-cenc-prk", + "hevc-hdr-main10-L41-dash-cenc-prk-do", + "hevc-hdr-main10-L30-dash-cenc-live", + "hevc-hdr-main10-L31-dash-cenc-live", + "hevc-hdr-main10-L40-dash-cenc-live", + "hevc-hdr-main10-L41-dash-cenc-live", + "iso_23001_18-dash-live", + "playready-h264hpl22-dash", + "h264hpl22-dash-playready-live", + "playready-h264hpl30-dash", + "h264hpl30-dash-playready-live", + "playready-h264hpl31-dash", + "h264hpl31-dash-playready-live", + "playready-h264hpl40-dash", + "h264hpl40-dash-playready-live", + "vp9-profile0-L21-dash-cenc", + "vp9-profile0-L30-dash-cenc", + "vp9-profile0-L31-dash-cenc", + "vp9-profile0-L40-dash-cenc", + "heaac-2-dash", + "xheaac-dash", + "imsc1.1", + "nflx-cmisc", + "BIF320" + ], + "profileGroups": [ + { + "name": "primary", + "profiles": [ + "none-h264mpl30-dash", + "playready-h264mpl30-dash", + "none-h264mpl31-dash", + "playready-h264mpl31-dash", + "none-h264mpl40-dash", + "playready-h264mpl40-dash", + "hevc-hdr-main10-L30-dash-cenc-prk", + "hevc-hdr-main10-L30-dash-cenc-prk-do", + "hevc-hdr-main10-L31-dash-cenc-prk", + "hevc-hdr-main10-L31-dash-cenc-prk-do", + "hevc-hdr-main10-L40-dash-cenc-prk", + "hevc-hdr-main10-L40-dash-cenc-prk-do", + "hevc-hdr-main10-L41-dash-cenc-prk", + "hevc-hdr-main10-L41-dash-cenc-prk-do", + "hevc-hdr-main10-L30-dash-cenc-live", + "hevc-hdr-main10-L31-dash-cenc-live", + "hevc-hdr-main10-L40-dash-cenc-live", + "hevc-hdr-main10-L41-dash-cenc-live", + "iso_23001_18-dash-live", + "playready-h264hpl22-dash", + "h264hpl22-dash-playready-live", + "playready-h264hpl30-dash", + "h264hpl30-dash-playready-live", + "playready-h264hpl31-dash", + "h264hpl31-dash-playready-live", + "playready-h264hpl40-dash", + "h264hpl40-dash-playready-live", + "vp9-profile0-L21-dash-cenc", + "vp9-profile0-L30-dash-cenc", + "vp9-profile0-L31-dash-cenc", + "vp9-profile0-L40-dash-cenc", + "heaac-2-dash", + "xheaac-dash", + "imsc1.1", + "nflx-cmisc", + "BIF320" + ] + } + ], + "cellularCap": "auto", + "manifestVersion": "v2", + "method": "licensedManifest", + "flavor": "PRE_FETCH", + "useBetterTextUrls": true, + "useHttpsStreams": true, + "drmType": "widevine", + "supportsWatermark": true, + "supportsPreReleasePin": true, + "requestEligibleABTests": true, + "supportsUnequalizedDownloadables": true, + "supportsAdBreakHydration": true, + "liveAdsCapability": "dynamic", + "netType": "wifi", + "osName": "android", + "osVersion": "34", + "application": "samurai", + "clientVersion": "9.57.0", + "uiVersion": "9.57.0", + "uiPlatform": "android", + "player": "streaming", + "hardware": "lito", + "uiContext": { + "uiFlavor": "android", + "clientAppVersion": "9.57.0", + "deviceTier": "AndroidDeviceTier", + "adCanvasUICapabilities": [ + "SlotBasedUI" + ] + }, + "challenges": { + "primary": [ + { + "challengeBase64": "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", + "drmSessionId": 1, + "clientTime": 1773420939 + } + ] + }, + "licenseType": "limited", + "contentPlaygraph": [ + "v2" + ], + "supportsAuxiliaryManifestDeduplication": true, + "viewableId": "80243261", + "liveMetadataFormat": "INDEXED_SEGMENT_TEMPLATE", + "maxSupportedLanguages": -1, + "supportsPartialHydration": true, + "prefersVerticalVideo": false, + "supportsVideoTrackSwitching": false, + "supportsNetflixMediaEvents": true + } + ] + } +} \ No newline at end of file diff --git a/docs/licensedManifest/L1_vs_L3_comparison.md b/docs/licensedManifest/L1_vs_L3_comparison.md new file mode 100644 index 0000000..2cbf918 --- /dev/null +++ b/docs/licensedManifest/L1_vs_L3_comparison.md @@ -0,0 +1,236 @@ +# licensedManifest: Widevine L1 vs L3 比較 + +Device: Pixel 4a (5G) / bramble / Android 14 +App: Netflix 9.57.0 +Endpoint: `https://android14.prod.ftl.netflix.com/nq/androidui/samurai/~9.0.0/api` +URL: `/licensedManifest` + +## 概要 + +同一デバイス (Pixel 4a) で、Widevine L1 (TEE) と L3 (ソフトウェア) の licensedManifest リクエストを比較。 +リクエスト構造は同一だが、**profiles** と **challenge** の内容が異なる。 + +## リクエスト共通部分 + +以下のフィールドは L1/L3 で完全に同一: + +| フィールド | 値 | +|---|---| +| version | 2 | +| url | /licensedManifest | +| languages | ["en-JP"] | +| method | licensedManifest | +| flavor | PRE_FETCH | +| drmType | widevine | +| manifestVersion | v2 | +| osName | android | +| osVersion | 34 | +| application | samurai | +| clientVersion | 9.57.0 | +| uiVersion | 9.57.0 | +| uiPlatform | android | +| player | streaming | +| hardware | lito | +| licenseType | limited | +| cellularCap | auto | +| netType | wifi | +| supportsWatermark | true | +| supportsPreReleasePin | true | +| supportsAdBreakHydration | true | +| liveAdsCapability | dynamic | +| supportsUnequalizedDownloadables | true | +| requestEligibleABTests | true | +| useBetterTextUrls | true | +| useHttpsStreams | true | +| contentPlaygraph | ["v2"] | +| supportsAuxiliaryManifestDeduplication | true | +| liveMetadataFormat | INDEXED_SEGMENT_TEMPLATE | +| maxSupportedLanguages | -1 | +| supportsPartialHydration | true | +| prefersVerticalVideo | false | +| supportsVideoTrackSwitching | false | +| supportsNetflixMediaEvents | true | + +## 差分1: Profiles + +### L1 (36 profiles) + +HEVC HDR + 高解像度H.264 + VP9を含む全プロファイル: + +``` +none-h264mpl30-dash +playready-h264mpl30-dash +none-h264mpl31-dash ← L3にはない +playready-h264mpl31-dash ← L3にはない +none-h264mpl40-dash ← L3にはない +playready-h264mpl40-dash ← L3にはない +hevc-hdr-main10-L30-dash-cenc-prk ← L3にはない (HEVC HDR) +hevc-hdr-main10-L30-dash-cenc-prk-do ← L3にはない +hevc-hdr-main10-L31-dash-cenc-prk ← L3にはない +hevc-hdr-main10-L31-dash-cenc-prk-do ← L3にはない +hevc-hdr-main10-L40-dash-cenc-prk ← L3にはない +hevc-hdr-main10-L40-dash-cenc-prk-do ← L3にはない +hevc-hdr-main10-L41-dash-cenc-prk ← L3にはない +hevc-hdr-main10-L41-dash-cenc-prk-do ← L3にはない +hevc-hdr-main10-L30-dash-cenc-live ← L3にはない +hevc-hdr-main10-L31-dash-cenc-live ← L3にはない +hevc-hdr-main10-L40-dash-cenc-live ← L3にはない +hevc-hdr-main10-L41-dash-cenc-live ← L3にはない +iso_23001_18-dash-live +playready-h264hpl22-dash +h264hpl22-dash-playready-live +playready-h264hpl30-dash +h264hpl30-dash-playready-live +playready-h264hpl31-dash ← L3にはない +h264hpl31-dash-playready-live ← L3にはない +playready-h264hpl40-dash ← L3にはない +h264hpl40-dash-playready-live ← L3にはない +vp9-profile0-L21-dash-cenc +vp9-profile0-L30-dash-cenc +vp9-profile0-L31-dash-cenc ← L3にはない +vp9-profile0-L40-dash-cenc ← L3にはない +heaac-2-dash +xheaac-dash +imsc1.1 +nflx-cmisc +BIF320 +``` + +### L3 (14 profiles) + +基本的なコーデックのみ。HEVC HDR なし、高解像度 H.264/VP9 なし: + +``` +none-h264mpl30-dash +playready-h264mpl30-dash +iso_23001_18-dash-live +playready-h264hpl22-dash +h264hpl22-dash-playready-live +playready-h264hpl30-dash +h264hpl30-dash-playready-live +vp9-profile0-L21-dash-cenc +vp9-profile0-L30-dash-cenc +heaac-2-dash +xheaac-dash +imsc1.1 +nflx-cmisc +BIF320 +``` + +### Profile 差分まとめ + +| カテゴリ | L1 | L3 | +|---|---|---| +| H.264 Main Profile | L30, L31, L40 | L30のみ | +| H.264 High Profile | L22, L30, L31, L40 | L22, L30のみ | +| HEVC HDR Main10 | L30, L31, L40, L41 (prk, prk-do, live) | なし | +| VP9 Profile0 | L21, L30, L31, L40 | L21, L30のみ | +| Audio/Subtitle/Other | heaac-2, xheaac, imsc1.1, nflx-cmisc, BIF320 | 同一 | +| **合計** | **36** | **14** | + +L3 では高解像度ビデオ (L31以上) と HEVC HDR が全て除外される。 +H.264基準では最大L30 (720p) だが、VP9 L30 (最大1080p) は含まれる。 +実際に返却されるストリーム品質はサーバー側の manifest レスポンスによる。 + +## 差分2: Challenge (Widevine License Request) + +Challenge はデバイスの Widevine CDM が生成する protobuf バイナリ (Base64エンコード)。 +デバイス証明書と OEMCrypto 情報を含む。 + +### L1 Challenge 内の Client Identification + +``` +application_name: com.netflix.mediaclient +origin: (empty) +package_certificate_hash_bytes: KAvwDZh6ZPI16jT59NO7L5XUj7ME5e6KKx0eXz0Zz9A= +company_name: Google +model_name: Pixel 4a (5G) +architecture_name: arm64-v8a +device_name: bramble +product_name: bramble +build_info: google/bramble/bramble:14/UP1A.231005.007/10754064:user/release-keys +widevine_cdm_version: 17.0.0 +oem_crypto_security_patch_level: 0 +oem_crypto_build_information: Build Information: API_Version: 16.3 LibOEMCrypto_Version: 1.56 TA_Version: 1.1382 +``` + +### L3 Challenge 内の Client Identification + +``` +application_name: com.netflix.mediaclient +origin: (empty) +package_certificate_hash_bytes: KAvwDZh6ZPI16jT59NO7L5XUj7ME5e6KKx0eXz0Zz9A= +company_name: Google +model_name: Pixel 4a (5G) +architecture_name: arm64-v8a +device_name: bramble +product_name: bramble +build_info: google/bramble/bramble:14/UP1A.231005.007/10754064:user/release-keys +widevine_cdm_version: 17.0.0 +oem_crypto_security_patch_level: 0 +oem_crypto_build_information: OEMCrypto Level3 Code May 20 2022 21:36:54 +``` + +### Challenge 差分まとめ + +| フィールド | L1 | L3 | +|---|---|---| +| oem_crypto_build_information | `Build Information: API_Version: 16.3 LibOEMCrypto_Version: 1.56 TA_Version: 1.1382` | `OEMCrypto Level3 Code May 20 2022 21:36:54` | +| RSA公開鍵 | 異なる (TEE内の鍵) | 異なる (ソフトウェア鍵) | +| DRM Session Provider Certificate | 異なる | 異なる | +| challengeBase64 長さ | ~3456文字 | ~3192文字 | + +L1 の challenge は TEE (Trusted Execution Environment) 内で生成され、TA (Trusted Application) バージョン情報を含む。 +L3 の challenge はソフトウェアで生成され、OEMCrypto Level3 のビルド日時を含む。 + +## 差分3: viewableId + +両方とも params 配列に3つの viewableId を含むが、viewableId 自体は同一: + +| params[n] | viewableId | +|---|---| +| params[0] | 81639724 | +| params[1] | 81756595 | +| params[2] | 80243261 | + +## Cookies + +MSL リクエストは `gsid` cookie で認証される。以下の cookie が使用される: + +| Cookie | 説明 | +|---|---| +| `nfvdid` | Netflix Virtual Device ID | +| `flwssn` | Flow Session ID | +| `NetflixId` | メイン認証 cookie | +| `SecureNetflixId` | セキュア認証 cookie (HMAC付き) | +| `gsid` | Global Session ID (MSLリクエスト用) | + +実際のcookie値は [cookies.txt](cookies.txt) を参照。 + +## Headers + +MSL リクエストは Cronet HTTP クライアント経由で送信される。 +主要ヘッダー: + +| Header | 値 | +|---|---| +| Content-Type | application/json | +| User-Agent | Dalvik/2.1.0 (Linux; U; Android 14; Pixel 4a (5G) Build/UP1A.231005.007) | + +## まとめ + +| 項目 | L1 (TEE) | L3 (Software) | +|---|---|---| +| **最大画質** | 4K HDR | 720p (H.264) / 1080p (VP9) ※要レスポンス確認 | +| **Profiles数** | 36 | 14 | +| **HEVC HDR** | あり (L30-L41) | なし | +| **H.264 最大Level** | L40 (1080p+) | L30 (720p) | +| **VP9 最大Level** | L40 (4K) | L30 (1080p) | +| **OEMCrypto** | TEE (TA_Version: 1.1382) | Software (Level3 Code) | +| **セキュリティ** | ハードウェア保護 | ソフトウェアのみ | +| **リクエスト構造** | 同一 | 同一 | +| **Cookie** | 同一 | 同一 | + +L1/L3 の違いは **challenge** と **profiles** のみ。リクエスト構造、cookie、ヘッダーは完全に同一。 +Netflix サーバーは challenge 内の OEMCrypto 情報からセキュリティレベルを判定し、 +返却するマニフェスト (利用可能なストリーム品質) を決定する。 diff --git a/docs/licensedManifest/L3_licensedManifest.json b/docs/licensedManifest/L3_licensedManifest.json new file mode 100644 index 0000000..747b34a --- /dev/null +++ b/docs/licensedManifest/L3_licensedManifest.json @@ -0,0 +1,289 @@ +{ + "event": "msl.api", + "ts": "2026-03-13T16:35:52.934Z", + "domain": "android14.prod.ftl.netflix.com", + "url": "https://android14.prod.ftl.netflix.com/nq/androidui/samurai/~9.0.0/api", + "headers": {}, + "body_size": 15877, + "userId": "ZEULH5S2GNGCRAABCSG6J2EGGA", + "userauthdata": null, + "body": { + "version": 2, + "url": "/licensedManifest", + "languages": [ + "en-JP" + ], + "params": [ + { + "profiles": [ + "none-h264mpl30-dash", + "playready-h264mpl30-dash", + "iso_23001_18-dash-live", + "playready-h264hpl22-dash", + "h264hpl22-dash-playready-live", + "playready-h264hpl30-dash", + "h264hpl30-dash-playready-live", + "vp9-profile0-L21-dash-cenc", + "vp9-profile0-L30-dash-cenc", + "heaac-2-dash", + "xheaac-dash", + "imsc1.1", + "nflx-cmisc", + "BIF320" + ], + "profileGroups": [ + { + "name": "primary", + "profiles": [ + "none-h264mpl30-dash", + "playready-h264mpl30-dash", + "iso_23001_18-dash-live", + "playready-h264hpl22-dash", + "h264hpl22-dash-playready-live", + "playready-h264hpl30-dash", + "h264hpl30-dash-playready-live", + "vp9-profile0-L21-dash-cenc", + "vp9-profile0-L30-dash-cenc", + "heaac-2-dash", + "xheaac-dash", + "imsc1.1", + "nflx-cmisc", + "BIF320" + ] + } + ], + "cellularCap": "auto", + "manifestVersion": "v2", + "method": "licensedManifest", + "flavor": "PRE_FETCH", + "useBetterTextUrls": true, + "useHttpsStreams": true, + "drmType": "widevine", + "supportsWatermark": true, + "supportsPreReleasePin": true, + "requestEligibleABTests": true, + "supportsUnequalizedDownloadables": true, + "supportsAdBreakHydration": true, + "liveAdsCapability": "dynamic", + "netType": "wifi", + "osName": "android", + "osVersion": "34", + "application": "samurai", + "clientVersion": "9.57.0", + "uiVersion": "9.57.0", + "uiPlatform": "android", + "player": "streaming", + "hardware": "lito", + "uiContext": { + "uiFlavor": "android", + "clientAppVersion": "9.57.0", + "deviceTier": "AndroidDeviceTier", + "adCanvasUICapabilities": [ + "SlotBasedUI" + ] + }, + "challenges": { + "primary": [ + { + "challengeBase64": "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", + "drmSessionId": 1, + "clientTime": 1773419752 + } + ] + }, + "licenseType": "limited", + "contentPlaygraph": [ + "v2" + ], + "supportsAuxiliaryManifestDeduplication": true, + "viewableId": "81639724", + "liveMetadataFormat": "INDEXED_SEGMENT_TEMPLATE", + "maxSupportedLanguages": -1, + "supportsPartialHydration": true, + "prefersVerticalVideo": false, + "supportsVideoTrackSwitching": false, + "supportsNetflixMediaEvents": true + }, + { + "profiles": [ + "none-h264mpl30-dash", + "playready-h264mpl30-dash", + "iso_23001_18-dash-live", + "playready-h264hpl22-dash", + "h264hpl22-dash-playready-live", + "playready-h264hpl30-dash", + "h264hpl30-dash-playready-live", + "vp9-profile0-L21-dash-cenc", + "vp9-profile0-L30-dash-cenc", + "heaac-2-dash", + "xheaac-dash", + "imsc1.1", + "nflx-cmisc", + "BIF320" + ], + "profileGroups": [ + { + "name": "primary", + "profiles": [ + "none-h264mpl30-dash", + "playready-h264mpl30-dash", + "iso_23001_18-dash-live", + "playready-h264hpl22-dash", + "h264hpl22-dash-playready-live", + "playready-h264hpl30-dash", + "h264hpl30-dash-playready-live", + "vp9-profile0-L21-dash-cenc", + "vp9-profile0-L30-dash-cenc", + "heaac-2-dash", + "xheaac-dash", + "imsc1.1", + "nflx-cmisc", + "BIF320" + ] + } + ], + "cellularCap": "auto", + "manifestVersion": "v2", + "method": "licensedManifest", + "flavor": "PRE_FETCH", + "useBetterTextUrls": true, + "useHttpsStreams": true, + "drmType": "widevine", + "supportsWatermark": true, + "supportsPreReleasePin": true, + "requestEligibleABTests": true, + "supportsUnequalizedDownloadables": true, + "supportsAdBreakHydration": true, + "liveAdsCapability": "dynamic", + "netType": "wifi", + "osName": "android", + "osVersion": "34", + "application": "samurai", + "clientVersion": "9.57.0", + "uiVersion": "9.57.0", + "uiPlatform": "android", + "player": "streaming", + "hardware": "lito", + "uiContext": { + "uiFlavor": "android", + "clientAppVersion": "9.57.0", + "deviceTier": "AndroidDeviceTier", + "adCanvasUICapabilities": [ + "SlotBasedUI" + ] + }, + "challenges": { + "primary": [ + { + "challengeBase64": "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", + "drmSessionId": 1, + "clientTime": 1773419752 + } + ] + }, + "licenseType": "limited", + "contentPlaygraph": [ + "v2" + ], + "supportsAuxiliaryManifestDeduplication": true, + "viewableId": "81756595", + "liveMetadataFormat": "INDEXED_SEGMENT_TEMPLATE", + "maxSupportedLanguages": -1, + "supportsPartialHydration": true, + "prefersVerticalVideo": false, + "supportsVideoTrackSwitching": false, + "supportsNetflixMediaEvents": true + }, + { + "profiles": [ + "none-h264mpl30-dash", + "playready-h264mpl30-dash", + "iso_23001_18-dash-live", + "playready-h264hpl22-dash", + "h264hpl22-dash-playready-live", + "playready-h264hpl30-dash", + "h264hpl30-dash-playready-live", + "vp9-profile0-L21-dash-cenc", + "vp9-profile0-L30-dash-cenc", + "heaac-2-dash", + "xheaac-dash", + "imsc1.1", + "nflx-cmisc", + "BIF320" + ], + "profileGroups": [ + { + "name": "primary", + "profiles": [ + "none-h264mpl30-dash", + "playready-h264mpl30-dash", + "iso_23001_18-dash-live", + "playready-h264hpl22-dash", + "h264hpl22-dash-playready-live", + "playready-h264hpl30-dash", + "h264hpl30-dash-playready-live", + "vp9-profile0-L21-dash-cenc", + "vp9-profile0-L30-dash-cenc", + "heaac-2-dash", + "xheaac-dash", + "imsc1.1", + "nflx-cmisc", + "BIF320" + ] + } + ], + "cellularCap": "auto", + "manifestVersion": "v2", + "method": "licensedManifest", + "flavor": "PRE_FETCH", + "useBetterTextUrls": true, + "useHttpsStreams": true, + "drmType": "widevine", + "supportsWatermark": true, + "supportsPreReleasePin": true, + "requestEligibleABTests": true, + "supportsUnequalizedDownloadables": true, + "supportsAdBreakHydration": true, + "liveAdsCapability": "dynamic", + "netType": "wifi", + "osName": "android", + "osVersion": "34", + "application": "samurai", + "clientVersion": "9.57.0", + "uiVersion": "9.57.0", + "uiPlatform": "android", + "player": "streaming", + "hardware": "lito", + "uiContext": { + "uiFlavor": "android", + "clientAppVersion": "9.57.0", + "deviceTier": "AndroidDeviceTier", + "adCanvasUICapabilities": [ + "SlotBasedUI" + ] + }, + "challenges": { + "primary": [ + { + "challengeBase64": "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", + "drmSessionId": 1, + "clientTime": 1773419752 + } + ] + }, + "licenseType": "limited", + "contentPlaygraph": [ + "v2" + ], + "supportsAuxiliaryManifestDeduplication": true, + "viewableId": "80243261", + "liveMetadataFormat": "INDEXED_SEGMENT_TEMPLATE", + "maxSupportedLanguages": -1, + "supportsPartialHydration": true, + "prefersVerticalVideo": false, + "supportsVideoTrackSwitching": false, + "supportsNetflixMediaEvents": true + } + ] + } +} \ No newline at end of file diff --git a/docs/licensedManifest/cookies.txt b/docs/licensedManifest/cookies.txt new file mode 100644 index 0000000..bd0eab9 --- /dev/null +++ b/docs/licensedManifest/cookies.txt @@ -0,0 +1,5 @@ +nfvdid=BQFmAAEBEEPj84LzHGpQ_ldxaVuQv8tgIBE9VAe3w-WeF5En4w5goMB6eLYVXqxblfzh23QC62wkeecrOuKtsfIRNC5GkWZ80HdbCeAoFLJ8LL5stGc-h87ykqdvoTT1Vg5jhdcHity8mbE5rciYfRNlegiejB_j +flwssn=183a999e-9ce9-4f29-ac9f-3f8b3982c817 +NetflixId=v%3D3%26ct%3DBgjHlOvcAxLcAxaXkmlM2qvCsbrtr0XpbwjVDLxR0nI99gjwMx9wk5xuOa9o7TlTvRJP0CNX9Lzf98yGGX9OISk79MhqtWSsxD_ptC46cN6XiI6WMCel1b-20jdr1pK19szdG-oCTKR4EcJhAZR9hNyZZ1zYpSjVzuV_L0OLFzd09sao83Eg4wLQGonJfxxB2tuo3qdgcgR-O2bDBRocl0WuwHcTxQsGYKJ24diGqk9dXil-gBmqgkYWg3WaDrEvUM06iP4WGP3KoNYIDR7e-GNxKCpV2YQsijzOIMKpLGPQJ8wFIhiB5dxclHADogDf1LAk1gqQAJW3PZcpJlYX7do_sZD4HZRH64KhnIP01i1jTP6KZLmb1y-pAvw9mpzW4oOawIZyJ_M8gULv38GrjnDkBrq-Jzw0cp416ArTkqP6n-GY3NGav7uXuF_twRBBf2WUIcb5leBfMWGqc7xuTMuivOITBp9w8Ii58RXRuVtRZskX0yoNcH7IUxRd06zc4NgIY3UQftOu05ad4YwL1UsFMsoZsU5i6HRwf0eMtWxoZhpPWE2TYrXkpG42SRcmnCVGjpk-KCLkQ-Kzbmdz6QFw0cwiuVpRQUolAkPcJfPvV06svz82PazwY77fuQD3oQ2QDA39GAYiDgoMghvviGnL9mJ_PsP1%26pg%3DZEULH5S2GNGCRAABCSG6J2EGGA%26ch%3DAQEAEAABABTp79nN9l_2MuRhqTXl0-SjAqcm83QU8vw. +SecureNetflixId=v%3D3%26mac%3DAQEAEQABABTPJ-U_tBsVO-9f3jqgLJEcmNaUyjnkrkY.%26dt%3D1773402757629 +gsid=d4779eb6-16d4-4179-89af-a069f7f1fc07 diff --git a/docs/manifest_android.md b/docs/manifest_android.md new file mode 100644 index 0000000..dc63cce --- /dev/null +++ b/docs/manifest_android.md @@ -0,0 +1,660 @@ +# Netflix licensedManifest API — Android ストリーミングプロファイル・画質指定 + +Netflix Android アプリが動画再生に必要なストリーム情報と DRM ライセンスを一括取得する `/licensedManifest` API の仕様。Frida フック (L3 強制環境) で取得した平文リクエストに基づく。 + +> **iOS との違い**: iOS 版では `/manifest` (マニフェスト) と `/license` (DRM ライセンス) が別リクエストだが、Android 版では `/licensedManifest` として**統合**されている。 + +--- + +## 1. 概要 + +`/licensedManifest` は MSL (Message Security Layer) で暗号化された API エンドポイント。マニフェスト取得と Widevine ライセンス取得を 1 リクエストで行う。バッチリクエストにより複数の `viewableId` を同時に指定可能。 + +--- + +## 2. 通信フロー + +```mermaid +%%{init: {'theme': 'dark'}}%% +sequenceDiagram + participant UI as Netflix UI + participant Player as Samurai Player + participant MSL as MSL Layer + participant Crypto as WidevineCryptoContext + participant DRM as MediaDrm (Widevine) + participant Server as android14.prod.ftl.netflix.com + + Note over UI: ユーザーがコンテンツを選択 + + rect rgba(180, 130, 50, 0.2) + Note over Player,DRM: DRM チャレンジ生成 + Player->>DRM: openSession() + DRM->>Player: sessionId + Player->>DRM: getKeyRequest(sessionId, initData, STREAMING) + DRM->>Player: challengeBase64 (Widevine protobuf) + end + + rect rgba(50, 160, 80, 0.2) + Note over Player,Server: licensedManifest (PRE_FETCH) + Player->>MSL: apiRequest("/licensedManifest", params) + Note over MSL: viewableId × N + challengeBase64 + MSL->>Crypto: encrypt(payload) + MSL->>Crypto: sign(message) + MSL->>Server: POST /nq/androidui/samurai/~9.0.0/api + Server->>MSL: MSL 暗号化レスポンス + MSL->>Crypto: decrypt(payload) + MSL->>Player: manifest JSON + Widevine ライセンス + end + + rect rgba(60, 100, 180, 0.2) + Note over Player,DRM: コンテンツ鍵インストール + Player->>DRM: provideKeyResponse(sessionId, license) + DRM->>Player: コンテンツ鍵有効化 + end + + rect rgba(130, 60, 160, 0.2) + Note over Player,Server: /license (standard, 再生開始時) + Player->>MSL: apiRequest("/license", params) + Note over MSL: licenseType=standard + challengeBase64 + MSL->>Server: POST (with playbackContextId, esn) + Server->>MSL: Widevine ライセンスレスポンス + end + + Note over Player: 再生開始 (CDN URL + コンテンツ鍵) +``` + +### 全体の流れ + +```mermaid +%%{init: {'theme': 'dark'}}%% +flowchart LR + subgraph "1. マニフェスト + ライセンス" + A["/licensedManifest
PRE_FETCH
(バッチ: N viewableIds)"] + end + + subgraph "2. 再生時ライセンス" + B["/license
standard
(challengeBase64)"] + end + + subgraph "3. ストリーム再生" + C["CDN からセグメント取得
(DASH + Widevine CENC)"] + end + + A --> B + B --> C + + style A fill:#6b4c1e,stroke:#c8943e,color:#f0d8a8 + style B fill:#1e3a6b,stroke:#3e78c8,color:#a8c8f0 + style C fill:#4a1e6b,stroke:#8e3ec8,color:#d0a8f0 +``` + +--- + +## 3. 暗号化 + +`/licensedManifest` のリクエスト・レスポンスは **MSL プロトコル (Widevine CryptoContext)** で暗号化されている。 + +```mermaid +%%{init: {'theme': 'dark'}}%% +flowchart TD + subgraph "リクエスト (クライアント → サーバー)" + R1["平文 JSON
(viewableId, profiles, challenge, ...)"] + R2["MSL PayloadChunk
WidevineCryptoContext.encrypt"] + R3["MSL MessageHeader
encrypt + sign"] + R4["HTTP POST Body
(CBOR 暗号化済み)"] + R1 -->|"encrypt()"| R2 + R1 -->|"sign()"| R3 + R2 --> R4 + R3 --> R4 + end + + subgraph "レスポンス (サーバー → クライアント)" + S1["HTTP Response Body
(CBOR 暗号化済み)"] + S2["CBOR dict
key 62 = GZIP データ"] + S3["平文 JSON
(CDN URL, ストリーム一覧, ライセンス)"] + S1 -->|"WidevineCryptoContext.decrypt()"| S2 + S2 -->|"gzip.decompress()"| S3 + end +``` + +| 項目 | 暗号化 | 状態 | +|---|---|---| +| リクエスト (profiles, viewableId, challenge 等) | MSL (Widevine CryptoContext) | **平文取得済み** — `apiRequest` フックで暗号化前に捕捉 | +| レスポンス (CDN URL, ストリーム一覧) | MSL + GZIP 圧縮 | **L3: 復元可能** — decrypt 平文 → CBOR → GZIP で復元 / L1: 未取得 | +| HTTP トランスポート | TLS 1.2/1.3 | TLS 上に MSL が重ねられている (二重暗号化) | +| 動画セグメント本体 | Widevine CENC (Common Encryption) | コンテンツ鍵は `/license` レスポンスから取得 | + +--- + +## 4. リクエストパラメータ + +`ApiHandlerImpl.apiRequest("/licensedManifest", params)` で送信される JSON。 + +### 4.1 全パラメータ一覧 + +```json +{ + "version": 2, + "url": "/licensedManifest", + "languages": ["en-JP"], + "common": { + "challenge": "" + }, + "params": [ + { + "viewableId": "81756595", + "profiles": [ ... ], + "profileGroups": [ ... ], + "challenges": { + "primary": [{ + "challengeBase64": "", + "drmSessionId": 1, + "clientTime": 1773373148 + }] + }, + "method": "licensedManifest", + "flavor": "PRE_FETCH", + "drmType": "widevine", + "manifestVersion": "v2", + "licenseType": "limited", + "cellularCap": "auto", + "netType": "wifi", + "useHttpsStreams": true, + "useBetterTextUrls": true, + "supportsWatermark": true, + "supportsPreReleasePin": true, + "requestEligibleABTests": true, + "supportsUnequalizedDownloadables": true, + "supportsAdBreakHydration": true, + "supportsPartialHydration": true, + "supportsAuxiliaryManifestDeduplication": true, + "supportsNetflixMediaEvents": true, + "supportsVideoTrackSwitching": false, + "prefersVerticalVideo": false, + "liveAdsCapability": "dynamic", + "liveMetadataFormat": "INDEXED_SEGMENT_TEMPLATE", + "maxSupportedLanguages": -1, + "contentPlaygraph": ["v2"], + "osName": "android", + "osVersion": "34", + "application": "samurai", + "clientVersion": "9.57.0", + "uiVersion": "9.57.0", + "uiPlatform": "android", + "player": "streaming", + "hardware": "lito", + "uiContext": { + "uiFlavor": "android", + "clientAppVersion": "9.57.0", + "deviceTier": "AndroidDeviceTier", + "adCanvasUICapabilities": ["SlotBasedUI"] + } + } + ] +} +``` + +### 4.2 主要パラメータの意味 + +| パラメータ | 型 | 説明 | +|---|---|---| +| `version` | number | API バージョン。`2` | +| `url` | string | MSL 内部パス。`"/licensedManifest"` | +| `languages` | string[] | 優先言語。`["en-JP"]` | +| `common.challenge` | string | **共通 Widevine チャレンジ** — 全 viewableId で共有される CDM protobuf | +| `params` | array | **バッチリクエスト** — 複数の viewableId を同時に指定可能 | +| `viewableId` | string | コンテンツ ID。Netflix の各作品/エピソードに割り当てられた一意の番号 | +| `profiles` | string[] | クライアントが対応するコーデック・画質プロファイルの一覧 | +| `profileGroups` | object[] | profiles をグループ分けした構造 | +| `challenges.primary` | array | **個別 Widevine チャレンジ** — viewableId ごとの CDM protobuf + `drmSessionId` | +| `method` | string | `"licensedManifest"` | +| `flavor` | string | `"PRE_FETCH"` = 先読み | +| `drmType` | string | `"widevine"` (iOS では `"fairplay"`) | +| `manifestVersion` | string | `"v2"` | +| `licenseType` | string | `"limited"` (PRE_FETCH) / `"standard"` (再生時) | +| `cellularCap` | string | セルラー回線時の帯域制限。`"auto"` | +| `netType` | string | 接続種別。`"wifi"` / `"cellular"` | +| `liveAdsCapability` | string | ライブ広告対応。`"dynamic"` | +| `liveMetadataFormat` | string | ライブメタデータ形式。`"INDEXED_SEGMENT_TEMPLATE"` (iOS: `"HLS"`) | +| `contentPlaygraph` | string[] | 再生グラフバージョン。`["v2"]` (iOS: `["start"]`) | +| `hardware` | string | SoC 名。`"lito"` (Qualcomm Snapdragon 765G) | +| `uiContext.deviceTier` | string | `"AndroidDeviceTier"` | +| `uiContext.adCanvasUICapabilities` | string[] | 広告 UI 対応。`["SlotBasedUI"]` | +| `maxSupportedLanguages` | number | `-1` = 無制限 | + +### 4.3 iOS 版にないパラメータ + +| パラメータ | 説明 | +|---|---| +| `common.challenge` | 共通チャレンジ (iOS では各リクエストに個別の SPC) | +| `challenges.primary[].drmSessionId` | Widevine DRM セッション ID | +| `challenges.primary[].clientTime` | クライアントタイムスタンプ | +| `method` | MSL 内部メソッド名 | +| `useBetterTextUrls` | 字幕 URL の改善版使用 | +| `requestEligibleABTests` | A/B テスト適格性リクエスト | +| `liveAdsCapability` | ライブ広告対応レベル | +| `supportsAuxiliaryManifestDeduplication` | 補助マニフェスト重複排除 | +| `supportsNetflixMediaEvents` | Netflix メディアイベント対応 | +| `supportsVideoTrackSwitching` | ビデオトラック切替対応 | + +--- + +## 5. プロファイル (profiles) — L3 環境 + +L3 (ソフトウェア) 強制環境で送信されるプロファイル一覧。L1 (TEE) 環境ではより多くの高解像度プロファイルが含まれる。 + +### 5.1 映像プロファイル + +#### H.264 (AVC) + +| プロファイル名 | コーデック | Level | 最大解像度目安 | 備考 | +|---|---|---|---|---| +| `none-h264mpl30-dash` | H.264 Main Profile | 3.0 | SD (~720x480) | DRM なし | +| `playready-h264mpl30-dash` | H.264 Main Profile | 3.0 | SD | PlayReady | +| `playready-h264hpl22-dash` | H.264 High Profile | 2.2 | ~352x288 | PlayReady | +| `playready-h264hpl30-dash` | H.264 High Profile | 3.0 | SD | PlayReady | +| `h264hpl22-dash-playready-live` | H.264 High Profile | 2.2 | 低解像度 | PlayReady (live) | +| `h264hpl30-dash-playready-live` | H.264 High Profile | 3.0 | SD | PlayReady (live) | + +> **L1 環境で追加されるプロファイル** (推定): `playready-h264mpl31-dash`, `playready-h264mpl40-dash`, `playready-h264hpl31-dash`, `playready-h264hpl40-dash`, `h264hpl31-dash-playready-live`, `h264hpl40-dash-playready-live` + +#### VP9 + +| プロファイル名 | コーデック | Level | 最大解像度目安 | +|---|---|---|---| +| `vp9-profile0-L21-dash-cenc` | VP9 Profile 0 | 2.1 | ~480x360 | +| `vp9-profile0-L30-dash-cenc` | VP9 Profile 0 | 3.0 | ~720x480 (SD) | + +> **L1 環境で追加されるプロファイル** (推定): `vp9-profile0-L31-dash-cenc`, `vp9-profile0-L40-dash-cenc` + +#### HEVC (H.265) — L1 環境のみ (推定) + +L3 環境ではキャプチャされていないが、L1 環境では以下が追加される: + +| プロファイル名 | コーデック | Level | 備考 | +|---|---|---|---| +| `hevc-hdr-main10-L30-dash-cenc` | HEVC HDR10 Main 10 | 3.0 | HDR | +| `hevc-hdr-main10-L31-dash-cenc` | HEVC HDR10 Main 10 | 3.1 | HDR | +| `hevc-hdr-main10-L40-dash-cenc` | HEVC HDR10 Main 10 | 4.0 | HDR | +| `hevc-hdr-main10-L41-dash-cenc` | HEVC HDR10 Main 10 | 4.1 | HDR | + +#### その他 + +| プロファイル名 | 用途 | +|---|---| +| `iso_23001_18-dash-live` | ISO 23001-18 ライブストリーミング | + +### 5.2 音声プロファイル + +| プロファイル名 | コーデック | チャンネル | 備考 | +|---|---|---|---| +| `heaac-2-dash` | HE-AAC v1 | 2ch (ステレオ) | 標準音声 | +| `xheaac-dash` | xHE-AAC | 2ch | 低ビットレート対応 | + +> **iOS との違い**: iOS では `heaac-2hq-dash` (HQ), `dd-5.1-dash` (Dolby Digital 5.1), `ddplus-5.1-dash` / `ddplus-5.1hq-dash` (Dolby Digital Plus), `ddplus-atmos-dash` (Dolby Atmos) も含まれる。Android L3 では Dolby 系プロファイルが含まれていない。 + +### 5.3 字幕・その他 + +| プロファイル名 | 種別 | 説明 | +|---|---|---| +| `imsc1.1` | 字幕 | IMSC 1.1 (Timed Text) | +| `nflx-cmisc` | メタデータ | Netflix 制御メタデータ (チャプター、スキップ情報等) | +| `BIF320` | サムネイル | BIF (Base Index Frames) 320px — シークバーのプレビュー | + +> **iOS との違い**: iOS では `webvtt-lssdh-ios13` / `webvtt-lssdh-ios8` (WebVTT), `BIF240` も含まれる。 + +### 5.4 プロファイルグループ (profileGroups) + +L3 環境では全プロファイルが単一の `primary` グループに属する: + +| グループ名 | 対象プロファイル | +|---|---| +| `primary` | 全プロファイル (映像 + 音声 + 字幕 + その他) | + +> **iOS との違い**: iOS では `live` (ライブ用), `ce3` (H.264 PlayReady), `ce4` (HEVC PRK) の 3 グループに分かれる。 + +--- + +## 6. バッチリクエスト + +iOS 版と異なり、Android 版では **複数の viewableId を 1 リクエストで同時に送信** できる。 + +キャプチャでは 3 つの viewableId が同時にリクエストされた: + +| viewableId | 用途 | +|---|---| +| `81756595` | コンテンツ 1 | +| `80243261` | コンテンツ 2 | +| `81774276` | コンテンツ 3 | + +各エントリは同一の `profiles`, `profileGroups`, `challenges` を含むが、`viewableId` のみ異なる。`common.challenge` で共通のチャレンジを 1 つ指定し、`params[].challenges.primary` で viewableId ごとの個別チャレンジも指定する構造。 + +--- + +## 7. 画質制御パラメータ + +| パラメータ | 値 | 画質への影響 | +|---|---|---| +| `profiles` | (上記一覧) | **対応コーデック/画質の上限**。サーバーはこの一覧に基づいてストリームを返す | +| `cellularCap` | `"auto"` | セルラー回線時の帯域上限。`auto` = アダプティブ | +| `netType` | `"wifi"` / `"cellular"` | ネットワーク種別。wifi 時は帯域制限が緩和される | +| `hardware` | `"lito"` | SoC 名。サーバー側でハードウェアデコード能力を判定 | +| `prefersVerticalVideo` | `false` | 縦動画優先 | +| `licenseType` | `"limited"` / `"standard"` | `limited` = プリフェッチ (制限付き), `standard` = 本再生 | + +### L1 vs L3 の画質差 + +| 項目 | L1 (TEE) | L3 (ソフトウェア) | +|---|---|---| +| 最大解像度 | FHD (1920x1080) 以上 | SD (~720x480) | +| HEVC HDR10 | 利用可能 | 利用不可 | +| VP9 最大 Level | L40 | L30 | +| H.264 最大 Level | HPL40 (FHD) | HPL30 (SD) | +| Dolby Audio | 利用可能 (推定) | 利用不可 | + +--- + +## 8. レスポンス + +### 8.1 取得状況 + +| 方法 | 状態 | +|---|---| +| `BaseHandler.processRequest` (直接取得) | **失敗** — `response: null` (ProGuard フィールド名不一致) | +| `WidevineCryptoContext.decrypt` (L3) | **復元済み** — CBOR (key 62) → GZIP → JSON で完全復元 | +| `WidevineCryptoContext.decrypt` (L1) | **不可** — TEE 内で処理されるため | + +### 8.2 レスポンス構造 (実データ) + +L3 Widevine decrypt → CBOR → GZIP 展開で復元。8 チャンク、合計 456KB。 + +```json +{ + "id": 1, + "version": 2, + "serverTime": 1773373149628, + "result": [ + { + "movieId": "81756595", + "packageId": "2596051", + "duration": 8523000, + "drmContextId": "2596051", + "playbackContextId": "E3-Bgj5tevc...", + "video_tracks": [{ "..." }], + "audio_tracks": [{ "..." }], + "timedtexttracks": [{ "..." }], + "servers": [{ "..." }], + "links": { "events": {}, "ldl": {}, "license": {} } + } + ] +} +``` + +> **バッチ**: `result` は配列。リクエストの `params` に含まれる viewableId ごとに 1 エントリ。キャプチャでは 3 件。 + +### 8.3 result エントリ詳細 + +| フィールド | 型 | 値 (例) | 説明 | +|---|---|---|---| +| `movieId` | string | `"81756595"` | コンテンツ ID | +| `packageId` | string | `"2596051"` | DRM パッケージ ID (`drmContextId` と対応) | +| `duration` | number | `8523000` | 再生時間 (ms)。8523 秒 = 2h22m3s | +| `drmContextId` | string | `"2596051"` | DRM コンテキスト ID | +| `playbackContextId` | string | `"E3-Bgj5tevc..."` | 再生セッション ID (後続 API URL に埋め込み) | + +### 8.4 video_tracks + +```json +{ + "trackType": "PRIMARY", + "new_track_id": "V:2:1;2;;primary;-1;none;-1;", + "dimensionsLabel": "2D", + "streams": [ + { + "content_profile": "playready-h264hpl30-dash", + "bitrate": 1050, + "peakBitrate": 2250, + "res_w": 960, + "res_h": 540, + "framerate_value": 24000, + "framerate_scale": 1001, + "size": 1181780966, + "downloadable_id": "1496730611", + "vmaf": 87, + "isDrm": true, + "urls": [ + {"cdn_id": 140368, "url": "https://ipv4-c062-osa001-ix.1.oca.nflxvideo.net/?o=1&v=23&e=..."}, + {"cdn_id": 140566, "url": "https://ipv4-c010-osa003-ix.1.oca.nflxvideo.net/?o=1&v=18&e=..."} + ], + "moov": {"offset": 108, "size": 1048}, + "sidx": {"offset": 1156, "size": 37036} + } + ] +} +``` + +L3 環境での映像ストリーム一覧: + +| content_profile | bitrate (kbps) | 解像度 | vmaf | isDrm | +|---|---|---|---|---| +| `playready-h264hpl22-dash` | 80 | 480x270 | 32 | true | +| `playready-h264hpl22-dash` | 100 | 480x270 | 39 | true | +| `playready-h264hpl30-dash` | 200 | 608x342 | 52 | true | +| `playready-h264hpl30-dash` | 350 | 608x342 | 64 | true | +| `playready-h264hpl30-dash` | 560 | 768x432 | 74 | true | +| `playready-h264hpl30-dash` | 750 | 768x432 | 79 | true | +| `playready-h264hpl30-dash` | 1050 | 960x540 | 87 | true | + +> **L3 制限**: 最大解像度 960x540 (SD)。L1 環境では 1920x1080 以上、HEVC HDR10 プロファイルも含まれる。 + +### 8.5 audio_tracks + +```json +{ + "trackType": "PRIMARY", + "channels": "2.0", + "language": "ja", + "languageDescription": "Japanese [Original]", + "profile": "xheaac-dash", + "bitrates": [32, 64, 96, 192], + "streams": [ + { + "content_profile": "xheaac-dash", + "bitrate": 192, + "size": 216960714, + "downloadable_id": "1758195596", + "channels": "2.0", + "isDrm": false, + "tags": ["SpeakerSpatialAudio"] + } + ] +} +``` + +| 言語 | プロファイル | ビットレート (kbps) | チャンネル | +|---|---|---|---| +| ja (Original) | xheaac-dash, heaac-2-dash | 32, 64, 96, 192 | 2.0 | +| en | xheaac-dash, heaac-2-dash | 32, 64, 96, 192 | 2.0 | +| pt-BR, es, es-ES, fr, de, it, pl, fil, hu | 同上 | 同上 | 2.0 | + +合計 19 トラック (11 言語 × xheaac-dash / heaac-2-dash、一部言語は片方のみ)。 + +### 8.6 timedtexttracks (字幕) + +```json +{ + "trackType": "PRIMARY", + "rawTrackType": "subtitles", + "language": "en", + "languageDescription": "Off", + "downloadableIds": {"imsc1.1": "945969240"}, + "ttDownloadables": { + "imsc1.1": { + "size": 16270, + "hashValue": "6uq3FARi9emju1mN/qygW1nd/fg=", + "hashAlgo": "sha1", + "urls": [ + {"cdn_id": 140368, "url": "https://ipv4-c062-osa001-ix.1.oca.nflxvideo.net/?o=1&v=23&e=..."} + ], + "isImage": false + } + }, + "isForcedNarrative": true +} +``` + +合計 57 トラック。フォーマットは全て `imsc1.1`。言語: en, ja, de, es, es-ES, fr, fil, hu, it, pl, pt-BR。 +種別: subtitles, closedcaptions。 + +### 8.7 servers (CDN) + +```json +{ + "id": 140368, + "key": "1-17676-high", + "name": "c062.osa001.ix.nflxvideo.net", + "rank": 1, + "type": "OPEN_CONNECT_APPLIANCE", + "lowgrade": false, + "dns": { + "host": "ipv4-c062-osa001-ix.1.oca.nflxvideo.net", + "ipv4": "45.57.82.139", + "ipv6": null, + "forceLookup": false + } +} +``` + +| rank | サーバー名 | IPv4 | ロケーション | +|---|---|---|---| +| 1 | c062.osa001.ix.nflxvideo.net | 45.57.82.139 | 大阪 (osa001) | +| 2 | c010.osa003.ix.nflxvideo.net | 23.246.52.11 | 大阪 (osa003) | +| 3 | c106.osa001.ix.nflxvideo.net | 45.57.84.3 | 大阪 (osa001) | + +全て `OPEN_CONNECT_APPLIANCE` タイプ。`rank` でフェイルオーバー優先度を指定。 + +### 8.8 links + +```json +{ + "events": { + "href": "/events?playbackContextId=E3-Bgj5tevc...&esn=NFANDROID1-PRV-P-L3-GOOGLPIXEL%3D4A%3D%3D5G%3D-22594-...", + "rel": "events" + }, + "ldl": { + "href": "/license?licenseType=limited&playbackContextId=...&esn=...&drmContextId=2596051", + "rel": "license" + }, + "license": { + "href": "/license?licenseType=standard&playbackContextId=...&esn=...&drmContextId=2596051", + "rel": "license" + } +} +``` + +| リンク | 用途 | 説明 | +|---|---|---| +| `events` | 再生イベント報告 | `playbackContextId` + `esn` を含む。keepAlive 等 | +| `ldl` | Limited Duration License | ビットレート変更時等の追加ライセンス取得 | +| `license` | Standard License | 再生開始時の本ライセンス取得 | + +> **ESN に L3 マーカー**: `esn` パラメータに `L3` が含まれる (`NFANDROID1-PRV-P-L3-...`)。L1 環境では `L1` になる。 + +--- + +## 9. /license リクエスト (再生開始時) + +PRE_FETCH 後、実際の再生開始時に `standard` ライセンスを個別に取得する。 + +### リクエスト URL + +``` +https://android14.prod.ftl.netflix.com/nq/androidui/samurai/~9.0.0/api + ?licenseType=standard + &playbackContextId= + &esn=NFANDROID1-PRV-P-GOOGLPIXEL%3D4A%3D%3D5G%3D-{userId}-{fingerprint} + &drmContextId=2596051 +``` + +### パラメータ + +| パラメータ | 値 | 説明 | +|---|---|---| +| `licenseType` | `"standard"` | 本再生用ライセンス | +| `playbackContextId` | JWT-like トークン | PRE_FETCH で取得した再生コンテキスト | +| `esn` | `NFANDROID1-PRV-P-...` | デバイス ESN | +| `drmContextId` | `2596051` | DRM コンテキスト ID | +| `challengeBase64` | Widevine CDM protobuf (~4KB) | ライセンスチャレンジ | +| `xid` | `"7616568730841682443"` | リクエストトレーシング ID | + +--- + +## 10. プロファイル名の命名規則 + +``` +{drm}-{codec}{profile}{level}-{container}[-{encryption}][-{variant}] +``` + +```mermaid +%%{init: {'theme': 'dark'}}%% +flowchart LR + subgraph "プロファイル名の構造" + A["playready"] --- B["h264"] --- C["hpl30"] --- D["dash"] + end + + A2["DRM
playready / none"] -.-> A + B2["コーデック
h264 / hevc / vp9"] -.-> B + C2["プロファイル+レベル
mpl (Main) / hpl (High)
profile0 (VP9)"] -.-> C + D2["コンテナ
dash (MPEG-DASH)"] -.-> D +``` + +| 略称 | 意味 | +|---|---| +| `hpl` | High Profile Level (H.264) | +| `mpl` | Main Profile Level (H.264) | +| `profile0` | Profile 0 (VP9, 8bit) | +| `hdr-main10` | Main 10 Profile (HEVC, 10bit HDR) | +| `L21`〜`L41` | コーデックレベル (解像度・ビットレート上限) | +| `dash` | MPEG-DASH コンテナ | +| `cenc` | Common Encryption (ISO 23001-7) | +| `playready` | Microsoft PlayReady DRM | +| `none` | DRM なし | +| `live` | ライブストリーミング | + +--- + +## 11. 画質を変更するには + +profiles 配列の内容を変更することで、サーバーから返されるストリームの種類が変わる。 + +| やりたいこと | profiles の変更 | +|---|---| +| H.264 のみにする | `vp9-*`, `hevc-*` プロファイルを全て削除 | +| VP9 のみにする | `*h264*`, `hevc-*` プロファイルを全て削除 | +| 最低画質に制限 | `none-h264mpl30-dash` + `heaac-2-dash` のみ残す | +| xHE-AAC を無効化 | `xheaac-dash` を削除 | +| 字幕を除外 | `imsc1.1` を削除 | +| サムネイルを除外 | `BIF320` を削除 | + +> **注意**: profiles を書き換えるには `apiRequest` フック内で `params` オブジェクトの profiles 配列を操作する必要がある。MSL 暗号化前のアプリ層で介入する。 + +--- + +## 12. iOS 版との比較 + +| 項目 | iOS (`/manifest`) | Android (`/licensedManifest`) | +|---|---|---| +| **API パス** | `/manifest` + `/license` (分離) | `/licensedManifest` (統合) | +| **バッチ** | 1 viewableId/リクエスト | 複数 viewableId/リクエスト | +| **DRM** | `fairplay` (SPC/CKC) | `widevine` (CDM protobuf) | +| **チャレンジ** | 個別の `challengeBase64` | `common.challenge` + 個別 `challenges.primary` | +| **映像コーデック** | H.264, HEVC Main 10 | H.264, VP9, HEVC HDR10 (L1 のみ) | +| **音声コーデック** | HE-AAC, DD 5.1, DD+ 5.1, Atmos | HE-AAC, xHE-AAC | +| **字幕** | WebVTT (LSSDH) | IMSC 1.1 | +| **サムネイル** | BIF240, BIF320 | BIF320 | +| **プロファイルグループ** | `live`, `ce3`, `ce4` (3グループ) | `primary` (単一グループ) | +| **画質ターゲット** | `desiredVmaf: "phone_plus_lts"` | なし (profiles のみ) | +| **ライブメタデータ** | `"HLS"` | `"INDEXED_SEGMENT_TEMPLATE"` | +| **再生グラフ** | `["start"]` | `["v2"]` | +| **広告対応** | `supportsAdBreakHydration` | `liveAdsCapability: "dynamic"` + `adCanvasUICapabilities` | +| **レスポンス** | 未取得 | L3: decrypt 経由で **復元済み** (456KB) | diff --git a/docs/manifest_ios.md b/docs/manifest_ios.md new file mode 100644 index 0000000..2280d12 --- /dev/null +++ b/docs/manifest_ios.md @@ -0,0 +1,514 @@ +# Netflix Manifest API — ストリーミングプロファイル・画質指定 + +Netflix iOS アプリが動画再生に必要なストリーム情報を取得する `/manifest` API の仕様。Frida フックで取得した平文リクエストに基づく。 + +--- + +## 1. 概要 + +`/manifest` は MSL (Message Security Layer) で暗号化された API エンドポイント。クライアントが再生可能なコーデック・画質・音声・字幕のプロファイル一覧をサーバーに送信し、サーバーはそれに基づいて利用可能なストリームと CDN URL を返す。 + +--- + +## 2. 通信フロー + +```mermaid +%%{init: {'theme': 'dark'}}%% +sequenceDiagram + participant UI as Netflix UI + participant Player as NFPlayerController + participant MSL as IosMslClient + participant Crypto as MslClient C++ + participant Server as ios.prod.cloud.netflix.com + + Note over UI: ユーザーがコンテンツを選択 + UI->>Player: load(viewableId) + + rect rgba(180, 130, 50, 0.2) + Note over Player,Server: PRE_FETCH フェーズ (先読み) + Player->>MSL: sendAPIRequest("/manifest", params) + Note over MSL: flavor: "PRE_FETCH" + MSL->>Crypto: aesCbcEncrypt(headerdata) + MSL->>Crypto: signHmacSha256(header) + MSL->>Crypto: aesCbcEncrypt(payload) + MSL->>Crypto: signHmacSha256(payload) + MSL->>Server: POST /msl/playapi/ios/manifest
(MSL 暗号化済み HTTP Body) + Server->>MSL: MSL 暗号化レスポンス + MSL->>Crypto: aesCbcDecrypt(payload) + MSL->>Player: manifest JSON (CDN URL, ストリーム一覧) + end + + rect rgba(50, 160, 80, 0.2) + Note over Player,Server: STANDARD フェーズ (再生開始) + Player->>MSL: sendAPIRequest("/manifest", params) + Note over MSL: flavor: "standard" + MSL->>Server: POST /msl/playapi/ios/manifest + Server->>MSL: MSL 暗号化レスポンス + MSL->>Player: manifest JSON + end + + rect rgba(60, 100, 180, 0.2) + Note over Player,Server: LICENSE フェーズ (DRM 鍵取得) + Player->>MSL: sendAPIRequest("/license", params) + Note over MSL: challengeBase64 (FairPlay SPC) + MSL->>Server: POST /msl/playapi/ios/license + Server->>MSL: CKC (Content Key Context) + MSL->>Player: FairPlay CKC → コンテンツ鍵展開 + end + + Note over Player: 再生開始 (CDN URL + コンテンツ鍵) +``` + +### 全体の流れ + +```mermaid +%%{init: {'theme': 'dark'}}%% +flowchart LR + subgraph "1. マニフェスト取得" + A["/manifest
PRE_FETCH"] --> B["/manifest
standard"] + end + + subgraph "2. DRM ライセンス" + C["/license
FairPlay SPC→CKC"] + end + + subgraph "3. ストリーム再生" + D["CDN からセグメント取得
(DASH + FairPlay暗号化)"] + end + + B --> C + C --> D + + style A fill:#6b4c1e,stroke:#c8943e,color:#f0d8a8 + style B fill:#1e5c2e,stroke:#3ea84e,color:#a8f0b8 + style C fill:#1e3a6b,stroke:#3e78c8,color:#a8c8f0 + style D fill:#4a1e6b,stroke:#8e3ec8,color:#d0a8f0 +``` + +--- + +## 3. 暗号化 + +`/manifest` のリクエスト・レスポンスは **MSL プロトコルで暗号化** されている。 + +```mermaid +%%{init: {'theme': 'dark'}}%% +flowchart TD + subgraph "リクエスト (クライアント → サーバー)" + R1["平文 JSON
(viewableId, profiles, ...)"] + R2["MSL PayloadChunk
AES-128-CBC 暗号化"] + R3["MSL MessageHeader
AES-128-CBC + HMAC-SHA256"] + R4["HTTP POST Body
(暗号化済みバイナリ)"] + R1 -->|"aesCbcEncrypt()"| R2 + R1 -->|"signHmacSha256()"| R3 + R2 --> R4 + R3 --> R4 + end + + subgraph "レスポンス (サーバー → クライアント)" + S1["HTTP Response Body
(暗号化済みバイナリ)"] + S2["MSL PayloadChunk
AES-128-CBC 暗号化"] + S3["平文 JSON
(CDN URL, ストリーム一覧)"] + S1 -->|"aesCbcDecrypt()"| S2 + S2 -->|"decompress (GZIP)"| S3 + end +``` + +| 項目 | 暗号化 | 状態 | +|---|---|---| +| リクエスト (profiles, viewableId 等) | MSL (AES-128-CBC + HMAC-SHA256) | **平文取得済み** — `sendAPIRequest` フックで暗号化前に捕捉 | +| レスポンス (CDN URL, ストリーム一覧) | MSL (AES-128-CBC + HMAC-SHA256) + GZIP 圧縮 | **未取得** — `sendAPIRequest` コールバックフック実装済みだが manifest レスポンスのログが出ていない (logblob の ACK のみ) | +| HTTP トランスポート | TLS 1.2/1.3 | TLS 上に MSL が重ねられている (二重暗号化) | +| 動画セグメント本体 | FairPlay CBCS (Common encryption) | コンテンツ鍵は `/license` CKC から取得 | + +--- + +## 4. リクエストパラメータ + +`IosMslClient.sendAPIRequest("/manifest", params)` で送信される JSON。 + +### 4.1 全パラメータ一覧 + +```json +{ + "viewableId": 81774276, + "flavor": "PRE_FETCH", + "profiles": [ ... ], + "profileGroups": [ ... ], + "drmType": "fairplay", + "manifestVersion": "v2", + "desiredVmaf": "phone_plus_lts", + "cellularCap": "auto", + "netType": "wifi", + "useHttpsStreams": true, + "supportsWatermark": true, + "supportsUnequalizedDownloadables": true, + "supportsPartialHydration": true, + "supportsAdBreakHydration": true, + "supportsSecureStop": false, + "supportsPreReleasePin": true, + "unletterboxed": false, + "liveMetadataFormat": "HLS", + "contentPlaygraph": ["start"], + "requiresAudioTrackGroups": true, + "preferAssistiveAudio": false, + "prefersClosedCaptions": false, + "hardware": "IPHONE9-1", + "osName": "iOS", + "osVersion": "15.8.3", + "uiPlatform": "ios", + "clientVersion": "15.48.1", + "platform": "2012.4", + "sdk": "2012.4", + "build": "24", + "xid": "7114306196730548973" +} +``` + +### 4.2 主要パラメータの意味 + +| パラメータ | 型 | 説明 | +|---|---|---| +| `viewableId` | number | コンテンツ ID。Netflix の各作品/エピソードに割り当てられた一意の番号 | +| `flavor` | string | `"PRE_FETCH"` = UI 表示時に先読み、`"standard"` = 実際の再生開始時 | +| `profiles` | string[] | **クライアントが対応するコーデック・画質プロファイルの一覧** (後述) | +| `profileGroups` | object[] | profiles をグループ分けした構造 (DRM スキーム別) | +| `drmType` | string | DRM 方式。iOS では常に `"fairplay"` | +| `manifestVersion` | string | マニフェストバージョン。`"v2"` | +| `desiredVmaf` | string | **画質ターゲット**。VMAF (Video Multi-method Assessment Fusion) ベースの品質スコア指定。`"phone_plus_lts"` = スマホ向け長期安定画質モード | +| `cellularCap` | string | **セルラー回線時の帯域制限**。`"auto"` = ネットワーク状況に応じて自動判定 | +| `netType` | string | 現在の接続種別。`"wifi"` or `"cellular"` | +| `useHttpsStreams` | boolean | ストリーム URL に HTTPS を使うか | +| `supportsWatermark` | boolean | フォレンジックウォーターマーク対応 | +| `supportsUnequalizedDownloadables` | boolean | 非均一品質のダウンロード可能セグメント対応 | +| `supportsPartialHydration` | boolean | 部分的なマニフェスト補完対応 | +| `unletterboxed` | boolean | レターボックス除去。`false` = そのまま | +| `liveMetadataFormat` | string | ライブコンテンツのメタデータ形式。`"HLS"` | +| `contentPlaygraph` | string[] | 再生グラフの状態。`["start"]` = 再生開始 | +| `xid` | string | リクエスト一意ID (トレーシング用) | +| `hardware` | string | デバイスモデル。例: `"IPHONE9-1"` (iPhone 7) | + +--- + +## 5. プロファイル (profiles) — コーデック・画質指定 + +`profiles` 配列がサーバーに「このデバイスはこれらのコーデック・画質に対応している」と伝える。サーバーはこの一覧に基づいて利用可能なストリームを返す。 + +### 5.1 映像プロファイル + +#### H.264 (AVC) — `profileGroups.name: "ce3"` および `"live"` + +| プロファイル名 | コーデック | Level | 最大解像度目安 | DRM | +|---|---|---|---|---| +| `h264hpl22-dash-playready-live` | H.264 High Profile | 2.2 | ~352x288 | PlayReady (live) | +| `h264hpl30-dash-playready-live` | H.264 High Profile | 3.0 | ~720x480 (SD) | PlayReady (live) | +| `h264hpl31-dash-playready-live` | H.264 High Profile | 3.1 | ~1280x720 (HD) | PlayReady (live) | +| `h264hpl40-dash-playready-live` | H.264 High Profile | 4.0 | ~1920x1080 (FHD) | PlayReady (live) | +| `playready-h264mpl30-dash` | H.264 Main Profile | 3.0 | SD | PlayReady | +| `playready-h264mpl31-dash` | H.264 Main Profile | 3.1 | HD | PlayReady | +| `playready-h264mpl40-dash` | H.264 Main Profile | 4.0 | FHD | PlayReady | +| `playready-h264hpl22-dash` | H.264 High Profile | 2.2 | 低解像度 | PlayReady | +| `playready-h264hpl30-dash` | H.264 High Profile | 3.0 | SD | PlayReady | +| `playready-h264hpl31-dash` | H.264 High Profile | 3.1 | HD | PlayReady | +| `playready-h264hpl40-dash` | H.264 High Profile | 4.0 | FHD | PlayReady | + +#### HEVC (H.265) — `profileGroups.name: "ce4"` + +| プロファイル名 | コーデック | Level | 最大解像度目安 | 備考 | +|---|---|---|---|---| +| `hevc-main10-L30-dash-cenc-live` | HEVC Main 10 | 3.0 | SD | ライブ | +| `hevc-main10-L31-dash-cenc-live` | HEVC Main 10 | 3.1 | HD | ライブ | +| `hevc-main10-L40-dash-cenc-live` | HEVC Main 10 | 4.0 | FHD | ライブ | +| `hevc-main10-L41-dash-cenc-live` | HEVC Main 10 | 4.1 | FHD+ | ライブ | +| `hevc-main10-L30-dash-cenc-prk` | HEVC Main 10 | 3.0 | SD | PlayReady Key | +| `hevc-main10-L31-dash-cenc-prk` | HEVC Main 10 | 3.1 | HD | PlayReady Key | +| `hevc-main10-L40-dash-cenc-prk` | HEVC Main 10 | 4.0 | FHD | PlayReady Key | +| `hevc-main10-L41-dash-cenc-prk` | HEVC Main 10 | 4.1 | FHD+ | PlayReady Key | +| `hevc-main10-L30-dash-cenc-prk-do` | HEVC Main 10 | 3.0 | SD | PRK + Download Offline | +| `hevc-main10-L31-dash-cenc-prk-do` | HEVC Main 10 | 3.1 | HD | PRK + Download Offline | +| `hevc-main10-L40-dash-cenc-prk-do` | HEVC Main 10 | 4.0 | FHD | PRK + Download Offline | +| `hevc-main10-L41-dash-cenc-prk-do` | HEVC Main 10 | 4.1 | FHD+ | PRK + Download Offline | + +> **Note**: `Main 10` は 10bit 色深度 (HDR 対応)。iPhone 7 (IPHONE9-1) は HEVC ハードウェアデコード対応。 + +### 5.2 音声プロファイル + +| プロファイル名 | コーデック | チャンネル | 備考 | +|---|---|---|---| +| `heaac-2-dash` | HE-AAC v1 | 2ch (ステレオ) | 標準音声 | +| `heaac-2hq-dash` | HE-AAC v1 | 2ch (ステレオ) | 高品質 (ビットレート高) | +| `dd-5.1-dash` | Dolby Digital (AC-3) | 5.1ch | サラウンド | +| `ddplus-5.1-dash` | Dolby Digital Plus (E-AC-3) | 5.1ch | サラウンド (効率的) | +| `ddplus-5.1hq-dash` | Dolby Digital Plus (E-AC-3) | 5.1ch | 高品質サラウンド | +| `ddplus-atmos-dash` | Dolby Atmos (E-AC-3 JOC) | オブジェクト | 空間オーディオ | + +### 5.3 字幕・その他 + +| プロファイル名 | 種別 | 説明 | +|---|---|---| +| `webvtt-lssdh-ios13` | 字幕 | WebVTT (iOS 13+, LSSDH 対応) | +| `webvtt-lssdh-ios8` | 字幕 | WebVTT (iOS 8+ レガシー) | +| `BIF240` | サムネイル | BIF (Base Index Frames) 240px — シークバーのプレビュー用 | +| `BIF320` | サムネイル | BIF 320px — シークバーのプレビュー用 | +| `nflx-cmisc` | メタデータ | Netflix 制御メタデータ (チャプター、スキップ情報等) | + +> `LSSDH` = Limited Streams for Simple Device Handling + +### 5.4 プロファイルグループ (profileGroups) + +profiles をDRM/暗号化方式ごとにグループ分けしたもの。サーバー側でストリーム選択時に使用される。 + +| グループ名 | 対象プロファイル | DRM 方式 | +|---|---|---| +| `live` | `h264hpl*-live`, `hevc-*-live` | ライブ/リアルタイム配信用 | +| `ce3` | `playready-h264*-dash` | PlayReady Content Encryption 3.0 (H.264) | +| `ce4` | `hevc-main10-*-prk`, `*-prk-do` | PlayReady Key (HEVC), Download Offline 含む | + +--- + +## 6. 画質制御パラメータ + +| パラメータ | 値 | 画質への影響 | +|---|---|---| +| `desiredVmaf` | `"phone_plus_lts"` | **VMAF 品質ターゲット**。サーバー側でエンコード品質 (ビットレート配分) を決定する基準。`phone_plus_lts` = スマホ画面向け + 長期安定 (Long Term Stable) | +| `cellularCap` | `"auto"` | セルラー回線時の帯域上限。`auto` = アダプティブ | +| `netType` | `"wifi"` / `"cellular"` | ネットワーク種別。wifi 時は帯域制限が緩和される | +| `profiles` | (上記一覧) | **対応コーデック/画質の上限**。ここに無いプロファイルのストリームはサーバーから返されない | +| `unletterboxed` | `false` | `true` にするとレターボックス (黒帯) を除去した映像が返される | +| `hardware` | `"IPHONE9-1"` | デバイスモデル。サーバー側でハードウェアデコード能力を判定 | + +### desiredVmaf の値 (推定) + +| 値 | 対象デバイス | +|---|---| +| `phone_plus_lts` | スマートフォン (小画面) | +| `tablet_plus_lts` | タブレット (中画面) | +| `tv_plus_lts` | テレビ/大画面 | + +> VMAF (Video Multi-method Assessment Fusion) は Netflix が開発した知覚品質メトリクス。同じビットレートでもコンテンツの複雑さに応じてエンコード品質を調整する (Per-Title Encoding / Dynamic Optimizer)。 + +--- + +## 7. リクエスト例 (実データ) + +```json +{ + "viewableId": 81774276, + "flavor": "PRE_FETCH", + "drmType": "fairplay", + "manifestVersion": "v2", + "desiredVmaf": "phone_plus_lts", + "cellularCap": "auto", + "netType": "wifi", + "useHttpsStreams": true, + "supportsWatermark": true, + "supportsUnequalizedDownloadables": true, + "supportsPartialHydration": true, + "supportsAdBreakHydration": true, + "supportsSecureStop": false, + "supportsPreReleasePin": true, + "unletterboxed": false, + "liveMetadataFormat": "HLS", + "contentPlaygraph": ["start"], + "requiresAudioTrackGroups": true, + "preferAssistiveAudio": false, + "prefersClosedCaptions": false, + "hardware": "IPHONE9-1", + "osName": "iOS", + "osVersion": "15.8.3", + "uiPlatform": "ios", + "clientVersion": "15.48.1", + "platform": "2012.4", + "sdk": "2012.4", + "build": "24", + "xid": "7114306196730548973", + "profiles": [ + "h264hpl22-dash-playready-live", + "h264hpl30-dash-playready-live", + "h264hpl31-dash-playready-live", + "h264hpl40-dash-playready-live", + "hevc-main10-L30-dash-cenc-live", + "hevc-main10-L31-dash-cenc-live", + "hevc-main10-L40-dash-cenc-live", + "hevc-main10-L41-dash-cenc-live", + "playready-h264mpl30-dash", + "playready-h264mpl31-dash", + "playready-h264mpl40-dash", + "playready-h264hpl22-dash", + "playready-h264hpl30-dash", + "playready-h264hpl31-dash", + "playready-h264hpl40-dash", + "hevc-main10-L30-dash-cenc-prk", + "hevc-main10-L31-dash-cenc-prk", + "hevc-main10-L40-dash-cenc-prk", + "hevc-main10-L41-dash-cenc-prk", + "hevc-main10-L30-dash-cenc-prk-do", + "hevc-main10-L31-dash-cenc-prk-do", + "hevc-main10-L40-dash-cenc-prk-do", + "hevc-main10-L41-dash-cenc-prk-do", + "heaac-2-dash", + "heaac-2hq-dash", + "dd-5.1-dash", + "ddplus-5.1-dash", + "ddplus-5.1hq-dash", + "ddplus-atmos-dash", + "webvtt-lssdh-ios13", + "nflx-cmisc", + "webvtt-lssdh-ios8", + "BIF240", + "BIF320" + ], + "profileGroups": [ + { + "name": "live", + "profiles": [ + "h264hpl22-dash-playready-live", + "h264hpl30-dash-playready-live", + "h264hpl31-dash-playready-live", + "h264hpl40-dash-playready-live", + "hevc-main10-L30-dash-cenc-live", + "hevc-main10-L31-dash-cenc-live", + "hevc-main10-L40-dash-cenc-live", + "hevc-main10-L41-dash-cenc-live" + ] + }, + { + "name": "ce3", + "profiles": [ + "playready-h264mpl30-dash", + "playready-h264mpl31-dash", + "playready-h264mpl40-dash", + "playready-h264hpl22-dash", + "playready-h264hpl30-dash", + "playready-h264hpl31-dash", + "playready-h264hpl40-dash" + ] + }, + { + "name": "ce4", + "profiles": [ + "hevc-main10-L30-dash-cenc-prk", + "hevc-main10-L31-dash-cenc-prk", + "hevc-main10-L40-dash-cenc-prk", + "hevc-main10-L41-dash-cenc-prk", + "hevc-main10-L30-dash-cenc-prk-do", + "hevc-main10-L31-dash-cenc-prk-do", + "hevc-main10-L40-dash-cenc-prk-do", + "hevc-main10-L41-dash-cenc-prk-do" + ] + } + ] +} +``` + +--- + +## 8. レスポンス (推定構造) + +manifest レスポンスはまだ捕捉できていないが、プロファイル名とリクエスト構造から以下の構造が推定される。 + +```json +{ + "version": 2, + "playbackContextId": "...", + "drmContextId": "...", + "urls": [ + { + "url": "https://ipv4-*.oca.nflxvideo.net/...", + "cdn_id": 12345 + } + ], + "video_tracks": [ + { + "type": "video", + "profile": "hevc-main10-L40-dash-cenc-prk", + "bitrate": 4500000, + "width": 1920, + "height": 1080, + "vmaf": 93.5, + "downloadable_id": "...", + "urls": [{ "url": "...", "cdn_id": 12345 }] + } + ], + "audio_tracks": [ + { + "type": "audio", + "profile": "ddplus-5.1-dash", + "bitrate": 640000, + "language": "en", + "channels": "5.1", + "downloadable_id": "..." + } + ], + "timedtexttracks": [ + { + "type": "text", + "profile": "webvtt-lssdh-ios13", + "language": "en", + "isForced": false, + "downloadable_id": "..." + } + ], + "drmHeader": { + "keyId": "...", + "initData": "..." + } +} +``` + +> **Note**: 実際のレスポンスを取得するには `sendAPIRequest` のコールバックフック (`ObjC.Block(args[7])`) で manifest レスポンスを捕捉する必要がある。現在 logblob の ACK レスポンスのみ取得されている状態。 + +--- + +## 9. プロファイル名の命名規則 + +``` +{codec}-{profile}-{level}-{container}-{encryption}[-{variant}] +``` + +```mermaid +%%{init: {'theme': 'dark'}}%% +flowchart LR + subgraph "プロファイル名の構造" + A["hevc"] --- B["main10"] --- C["L40"] --- D["dash"] --- E["cenc"] --- F["prk-do"] + end + + A2["コーデック
h264 / hevc"] -.-> A + B2["プロファイル
hpl (High) / mpl (Main)
main10 (10bit)"] -.-> B + C2["レベル
L22〜L41
(解像度/帯域上限)"] -.-> C + D2["コンテナ
dash (MPEG-DASH)"] -.-> D + E2["暗号化
cenc (Common Encryption)
playready"] -.-> E + F2["バリアント
prk (PlayReady Key)
do (Download Offline)
live (ライブ)"] -.-> F +``` + +| 略称 | 意味 | +|---|---| +| `hpl` | High Profile Level (H.264) | +| `mpl` | Main Profile Level (H.264) | +| `main10` | Main 10 Profile (HEVC, 10bit HDR 対応) | +| `L30`〜`L41` | コーデックレベル (解像度・ビットレート上限を規定) | +| `dash` | MPEG-DASH コンテナ | +| `cenc` | Common Encryption (ISO 23001-7) | +| `playready` | Microsoft PlayReady DRM | +| `prk` | PlayReady Key | +| `do` | Download Offline (オフライン再生用) | +| `live` | ライブ/リアルタイムストリーミング | +| `hq` | High Quality (高ビットレート) | +| `lssdh` | Limited Streams for Simple Device Handling | + +--- + +## 10. 画質を変更するには + +profiles 配列の内容を変更することで、サーバーから返されるストリームの種類が変わる。 + +| やりたいこと | profiles の変更 | +|---|---| +| H.264 のみにする | `hevc-*` プロファイルを全て削除 | +| 720p 以下に制限 | `*L40*`, `*L41*` プロファイルを削除 | +| SD のみにする | `*L31*`, `*L40*`, `*L41*` を削除 | +| Dolby Atmos を無効化 | `ddplus-atmos-dash` を削除 | +| ステレオ音声のみ | `dd-*`, `ddplus-*` を全て削除 | +| ダウンロード用を除外 | `*-do` プロファイルを削除 | + +> **注意**: profiles を書き換えるには `sendAPIRequest` フック内で `params` オブジェクトの profiles 配列を操作する必要がある。MSL 暗号化前のアプリ層で介入する。 diff --git a/docs/msl_android.md b/docs/msl_android.md new file mode 100644 index 0000000..65c50bc --- /dev/null +++ b/docs/msl_android.md @@ -0,0 +1,864 @@ +# Netflix Message Security Layer (MSL) — Android 動的解析レポート + +Netflix Android アプリ (`com.netflix.mediaclient` v9.57.0 / build 63928) を Frida で動的フックし、MSL 通信データをキャプチャした結果をまとめる。ProGuard 難読化済み Java バイトコードをランタイムでインターセプトしている。 + +> **キャプチャ環境**: Widevine セキュリティレベルを **L3 (ソフトウェア)** に強制変更した状態で取得。L1 (TEE) 環境でのキャプチャデータは未取得。L3 では暗号処理がソフトウェアで実行されるため、Frida による decrypt 平文の取得が可能になっている。 + +--- + +## 1. MSL プロトコル概要 + +MSL (Message Security Layer) は Netflix が独自開発した TLS 上のアプリケーション層セキュリティプロトコル。iOS 版と同一の仕様だが、Android 版では Widevine ベースの暗号コンテキストを使用する。 + +### プロトコルの目的 + +- **エンティティ認証**: デバイス (ESN) をサーバーに証明する (Widevine デバイス証明書) +- **ユーザー認証**: Netflix アカウントの認証 (SSO Token, UserIdToken 等) +- **鍵交換**: Widevine 鍵交換によるセッション鍵の安全な確立 +- **メッセージ保護**: ペイロードの暗号化 (Widevine CryptoContext) と署名 (HMAC-SHA256) +- **トークン管理**: MasterToken / UserIdToken / ServiceToken によるセッション維持 +- **ALE (Application Level Encryption)**: ソケットレベル暗号化 (RSA-OAEP-256 + A128GCM) + +--- + +## 2. アーキテクチャ + +### 2.1 アプリ構成 + +```mermaid +%%{init:{'theme':'dark'}}%% +graph TB + subgraph "com.netflix.mediaclient (Android)" + A[Netflix App
Samurai UI Framework] --> B[com.netflix.msl.*
MSL プロトコルコア] + A --> C[com.netflix.ale.*
Application Level Encryption] + A --> D[OkHttp3
HTTP クライアント] + A --> E[android.media.MediaDrm
Widevine DRM] + A --> F[Bugsnag
クラッシュ分析] + B --> G[WidevineCryptoContext
暗号化/復号/署名] + B --> H[MslControl
プロトコル制御] + B --> I[BaseHandler
リクエスト処理] + C --> J[AleCryptoBouncyCastle
ALE 暗号実装] + end +``` + +### 2.2 ProGuard 難読化マッピング + +Android アプリは ProGuard により難読化されており、暗号メソッド名が短縮されている: + +| 元メソッド名 | 難読化名 | クラス | +|---|---|---| +| `encrypt` | (元のまま) | `WidevineCryptoContext` | +| `decrypt` | `c` (2引数: `[B, MslEncoderFactory`) | `WidevineCryptoContext` | +| `sign` | `b` (3引数) | `WidevineCryptoContext` | +| `verify` | `c` (3引数, 戻り値 `boolean`) | `WidevineCryptoContext` | +| `wrap` | `c` (3引数, 戻り値 `[B`) | `JsonWebEncryptionCryptoContext` | +| `unwrap` | `d` | `JsonWebEncryptionCryptoContext` | + +### 2.3 デバイス情報 + +| 項目 | 値 | +|---|---| +| **ESN** | `NFANDROID1-PRV-P-GOOGLPIXEL=4A==5G=-{userId}-{fingerprint}` | +| **デバイス** | Google Pixel 4a (5G), codename `bramble` | +| **SoC** | Qualcomm `lito` | +| **Android** | 14 (API 34), build `UP1A.231005.007` | +| **アプリバージョン** | 9.57.0 (build 63928) | +| **プラットフォーム** | Android Tanto (Samurai UI) | +| **Widevine CDM** | v17.0.0 | +| **セキュリティレベル** | L3 (ソフトウェア) に強制変更済み — 本来は L1 (TEE), OEMCrypto v1.56 | +| **Bugsnag** | v6.25.0 | +| **jailbroken** | `true` (root 検出) | + +--- + +## 3. MSL メッセージ構造 + +iOS 版と同一仕様。ただしエンコーディングは CBOR を使用。 + +### 3.1 全体構造 + +```mermaid +%%{init:{'theme':'dark'}}%% +graph LR + subgraph "MSL メッセージ (CBOR)" + H[Header] --> P1[PayloadChunk 1] + P1 --> P2[PayloadChunk 2] + P2 --> PN[PayloadChunk N
endofmsg=true] + end +``` + +### 3.2 CBOR エンコーディング + +キャプチャされた Widevine encrypt の平文データから、MSL メッセージが CBOR (Concise Binary Object Representation) でエンコードされていることを確認: + +- プレフィックス `d9d9f7` = CBOR Self-Described Tag (RFC 8949) +- 埋め込みフィールド: + - `profileidx`: ユーザープロファイル ID (例: `ZEULH5S2GNGCRAABCSG6J2EGGA`) + - 圧縮アルゴリズム: `GZIP` + - エンコーダ: `CBOR` + - 発行者: `Netflix` + - `maxpayloadchunksize`: 最大ペイロードチャンクサイズ + +### 3.3 CBOR 整数キーマッピング + +CBOR エンコード時、JSON の文字列キーはプロトコル仕様で定義された**固定の整数キー**に置換される。完全なマッピングは `userauthdata_android.md` §6 を参照。主要キー: + +| CBOR キー | JSON フィールド名 | 使用箇所 | +|---|---|---| +| `15` | `tokendata` | MasterToken, UserIdToken, keyrequestdata | +| `16` | `signature` | 同上 | +| `17` | `mastertoken` | MessageHeader | +| `18` | `useridtoken` | MessageHeader | +| `19` | `renewable` | MessageHeader | +| `20` | `sender` | MessageHeader | +| `21` | `handshake` | MessageHeader | +| `22` | `messageid` | MessageHeader, PayloadChunk | +| `24` | `timestamp` | MessageHeader | +| `30` | `scheme` | keyrequestdata, userauthdata | +| `36` | `capabilities` | MessageHeader | +| `42` | `keyrequestdata` | MessageHeader | +| `47` | `userauthdata` | MessageHeader | +| `14` | `sequencenumber` | PayloadChunk | +| `44` | `compressionalgo` | PayloadChunk | +| `62` | `data` | PayloadChunk | +| `63` | `endofmsg` | PayloadChunk | + +### 3.4 MessageHeader / PayloadChunk / ErrorHeader + +構造は iOS 版 (`msl_ios.md` §3) と同一。暗号化アルゴリズムが AES-CBC から Widevine CryptoContext に変更されている点のみ異なる。 + +--- + +## 4. 暗号スタック + +### 4.1 鍵交換フロー + +```mermaid +%%{init:{'theme':'dark'}}%% +sequenceDiagram + participant C as Client (Android) + participant S as Server (netflix.com) + + C->>S: MessageHeader + keyrequestdata
(scheme: WIDEVINE) + Note over C: Widevine デバイス証明書で
エンティティ認証 + S->>C: MessageHeader + keyresponsedata
(mastertoken 付き) + Note over C: Widevine CryptoContext で
セッション鍵を確立 + C->>S: 以降のメッセージは
WidevineCryptoContext.encrypt + sign で保護 +``` + +### 4.2 暗号コンテキスト + +iOS 版が C++ の `aesCbcEncrypt` / `signHmacSha256` を使用するのに対し、Android 版は Java の `WidevineCryptoContext` を通じて Widevine CDM に暗号処理を委譲する。 + +> **L1 vs L3**: L1 (TEE) では暗号処理がハードウェアセキュアエレメント内で行われ、Frida から平文を取得できない。本キャプチャでは L3 (ソフトウェア) に強制変更しており、`encrypt` / `decrypt` の平文を Frida で捕捉可能になっている。 + +| コンポーネント | クラス | 用途 | キャプチャ数 | +|---|---|---|---| +| **Widevine 暗号化** | `WidevineCryptoContext.encrypt` | MSL ペイロード暗号化 | 322 | +| **Widevine 復号** | `WidevineCryptoContext.c` (2引数) | MSL レスポンス復号 | 36 | +| **Widevine 署名** | `WidevineCryptoContext.b` (3引数) | MSL メッセージ HMAC 署名 | 322 | +| **HMAC-SHA256** | `HmacSha256Signer.a` | エンティティ認証データ署名 | 1 | +| **AES-CBC** | `AesCbcEncryptor` | (Widevine 使用時は不使用) | 0 | +| **JWE** | `JsonWebEncryptionCryptoContext` | (未観測) | 0 | + +### 4.3 Widevine 署名の詳細 + +キャプチャされた `msl.widevine.sign` イベントから: + +- **データ**: CBOR エンコードされた MSL メッセージヘッダー/ペイロード (典型的に ~1247 バイト) +- **署名サイズ**: 32 バイト (HMAC-SHA256 相当) +- 全送信メッセージに対して `encrypt` と `sign` が 1:1 で呼ばれる (322:322) + +### 4.4 初回エンティティ認証 + +`msl.hmacSha256.sign` が 1 回だけ呼ばれ、ESN (`NFANDROID1-PRV-P-GOOGLPIXEL=4A==5G=`) で始まるデータ (2722 バイト) を署名。これは Widevine デバイス証明書を含むエンティティ認証データ。 + +--- + +## 5. ALE (Application Level Encryption) + +iOS 版には存在しない Android 固有の暗号化レイヤー。ソケットレベルの通信を追加で暗号化する。 + +### 5.1 アーキテクチャ + +```mermaid +%%{init:{'theme':'dark'}}%% +sequenceDiagram + participant App as Netflix App + participant ALE as AleService + participant Crypto as AleCryptoBouncyCastle + participant Server as Netflix Server + + App->>ALE: getProvisioningRequest() + Note over ALE: RSA-2048 エフェメラル鍵ペア生成 + ALE->>Server: provisionRequest
(RSA-OAEP-256 公開鍵, A128GCM, SOCKETROUTER) + Server->>ALE: provisionResponse
(JWE トークン + ラップ済みセッション鍵) + ALE->>ALE: createSession(token) + + App->>ALE: encrypt(plaintext) + ALE->>Crypto: aesGcmEncrypt(plaintext) + Crypto->>ALE: ciphertext + ALE->>ALE: JweBase.encrypt(ciphertext) +``` + +### 5.2 プロビジョニングリクエスト + +キャプチャから確認された構造: + +```json +{ + "provisionRequest": { + "keyx": { + "data": { "pubkey": "" }, + "scheme": "RSA-OAEP-256" + }, + "scheme": "A128GCM", + "type": "SOCKETROUTER", + "ver": 1 + } +} +``` + +- **鍵交換**: RSA-OAEP-256 でセッション鍵を安全に交換 +- **対称暗号**: A128GCM (AES-128-GCM) +- **セッションタイプ**: `SOCKETROUTER` +- 2 回のプロビジョニング呼び出しを観測 (それぞれ異なるエフェメラル RSA-2048 鍵) + +### 5.3 セッション作成 + +プロビジョニング応答で受け取った JWE トークンでセッションを確立: + +- **JWE ヘッダー**: `alg: A128GCMKW`, `enc: A128GCM`, `ver: 1` +- **セッション TTL**: 1800 秒 +- **更新ウィンドウ**: 600 秒 +- **鍵交換**: `RSA-OAEP-256` でラップされたセッション鍵 + +### 5.4 暗号パイプライン + +同一の 24 バイト平文が 3 つのレイヤーを通過するのを確認: + +1. `ale.encrypt` — `AleSession.encrypt(String)` で暗号化開始 +2. `ale.aesGcmEncrypt` — `AleCryptoBouncyCastle.aesGcmEncrypt` で AES-GCM 暗号化 +3. `ale.jwe.encrypt` — `JweBase.encrypt(byte[])` で JWE 形式にラップ + +--- + +## 6. Widevine DRM + +### 6.1 DRM セッション管理 + +```mermaid +%%{init:{'theme':'dark'}}%% +sequenceDiagram + participant App as Netflix App + participant DRM as android.media.MediaDrm + participant CDM as Widevine CDM (TEE) + + App->>DRM: openSession() + DRM->>CDM: セッション開始 + CDM->>App: sessionId (sid74, sid75, ...) + + App->>DRM: getKeyRequest(sessionId, initData, "application/xml", STREAMING) + DRM->>CDM: ライセンスチャレンジ生成 + CDM->>App: keyRequest (Widevine protobuf ~2378B) + + Note over App: keyRequest を MSL /license API で
challengeBase64 として送信 + + App->>DRM: provideKeyResponse(sessionId, response) + DRM->>CDM: コンテンツ鍵をインストール +``` + +### 6.2 デバイス証明書情報 + +`drm.keyRequest` の Widevine protobuf から抽出 (L3 強制環境): + +> **L1 との差異**: L1 環境では OEMCrypto がハードウェア TEE 内で動作し、`oem_crypto_build_information` に `OEMCrypto Level1` と表示される。L3 では `OEMCrypto Level3 Code` となり、暗号処理がユーザー空間で実行されるため Frida によるインターセプトが可能。ストリーミングプロファイルも L1 では HEVC HDR10 / 高解像度が利用可能だが、L3 では H.264 / VP9 の低解像度に制限される。 + +| 項目 | 値 | +|---|---| +| **CDM バージョン** | 17.0.0 | +| **OEMCrypto API** | v16.3 | +| **LibOEMCrypto** | v1.56 | +| **TA バージョン** | 1.1382 | +| **セキュリティレベル** | L3 (ソフトウェア) — 本来は L1 (TEE) | +| **ベンダー** | Google | +| **デバイス名** | bramble | +| **アーキテクチャ** | arm64-v8a | +| **ビルド** | `google/bramble/bramble:14/UP1A.231005.007/10754064:user/release-keys` | + +### 6.3 キャプチャされた DRM イベント + +| イベント | 件数 | 内容 | +|---|---|---| +| `drm.openSession` | 4 | セッション ID: sid74, sid75, sid77, sid78 | +| `drm.keyRequest` | 4 | keyType=STREAMING, mimeType=application/xml | +| `drm.keyResponse` | 2 | ライセンスメタデータ (ESN, movieId, issuetime) | +| `drm.propertyString` | 6 | securityLevel=L1, vendor=Google, description=Widevine CDM | +| `drm.property` | 1 | deviceUniqueId (32 バイト) | + +--- + +## 7. 通信先ドメイン + +### 7.1 Appboot — 初回起動 + +#### `appboot.netflix.com` + +| 項目 | 内容 | +|---|---| +| **プロトコル** | HTTPS (非 MSL) | +| **通信内容** | アプリ起動時に MSL TrustStore、JS TrustStore、TLS 設定を取得 | +| **復号状況** | **レスポンス: 取得済み** — `AppbootHandlerImpl.appbootExecute` フックで完全捕捉 | + +キャプチャされた Appboot レスポンス: + +```json +{ + "jtruststore": { + "keys": { "CHB4+ATbieuZVWvMdlu3Ow==": "" }, + "jsVerificationEnabled": true + }, + "msltruststore": { + "keys": { "MSL_TRUSTED_NETWORK_SERVER_KEY": "" } + }, + "tls_cipher_suites": "ECDHE-ECDSA-AES128-GCM-SHA256:...", + "tls_cipher_suites_by_version": { + "1.2": "ECDHE-ECDSA-AES128-GCM-SHA256:...", + "1.3": "TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256" + }, + "servertime_seconds": 1773370561 +} +``` + +- **`msltruststore`**: MSL 鍵交換で使用するサーバー RSA 公開鍵 +- **`jtruststore`**: JavaScript 検証用の公開鍵 +- **`tls_cipher_suites`**: TLS 1.2/1.3 で使用する暗号スイート一覧 + +### 7.2 MSL API エンドポイント + +#### `android14.prod.ftl.netflix.com` — FTL (低遅延) エンドポイント + +| 項目 | 内容 | +|---|---| +| **プロトコル** | HTTPS + MSL | +| **メソッド** | POST | +| **通信内容** | MSL で暗号化されたペイロードを送受信。DRM・マニフェスト・再生イベント系 | + +MSL API (`ApiHandlerImpl.apiRequest` 経由): + +| MSL パス / URL | 用途 | 詳細 | 復号状況 | +|---|---|---|---| +| `/aleProvision` | **ALE プロビジョニング** | RSA-OAEP-256 公開鍵と A128GCM スキームを送信し、ALE セッション鍵を取得。`netflixClientPlatform: androidNative` | **リクエスト: 取得済み** (2件) | +| `/licensedManifest` | **マニフェスト + ライセンス一括取得** | `viewableId` を指定し、ストリームプロファイル一覧と Widevine ライセンスチャレンジを同時送信。`drmType: "widevine"`, `flavor: "PRE_FETCH"`, `manifestVersion: "v2"` | **リクエスト: 取得済み** (1件、3 viewableIds 同時) | +| `/license` | **DRM ライセンス取得** | Widevine `challengeBase64` (CDM ライセンスチャレンジ ~4KB) を送信。`licenseType=standard`, `playbackContextId`, `drmContextId`, `esn` をクエリパラメータで指定 | **リクエスト: 取得済み** (1件) | +| `bundle` (`/events`) | **再生イベント送信** | keepAlive イベントを ~60秒間隔で送信。position (再生位置), trackId, mediaId, playTimes, CDN 情報を含む | **リクエスト: 取得済み** (5件) | +| `config` (Falcor) | **設定取得** | `/nq/androidui/samurai/v1/config` — deviceConfig, hendrixConfig, networkScoreConfig, streamingConfig2 等 | **リクエスト: 取得済み** (1件) | + +#### `android14.prod.cloud.netflix.com` — Cloud (AWS) エンドポイント + +| 項目 | 内容 | +|---|---| +| **プロトコル** | HTTPS + MSL | +| **メソッド** | POST | +| **パス** | `/graphql` | +| **通信内容** | GraphQL API による UI データ取得。FTL のフォールバック先 | + +GraphQL オペレーション: + +| オペレーション名 | Persisted Query ID | 用途 | 件数 | +|---|---|---|---| +| `RenewSSOToken` | `a4d00303-b02d-47c9-a53f-776b6a63b001` | SSO トークン更新 | 1 | +| `InterstitialForLolomo` | `3658ef5b-0c6d-4c7d-a5c0-6ae11405ee1d` | ホーム画面インタースティシャル | 1 | +| `InterstitialForProfileGate` | `18f3ae27-a0f1-45c9-88e6-c6bd39159ecb` | プロフィール選択画面 | 1 | +| `AccountQuery` | `4043dd89-0ed5-4d7f-ac5c-40c7ffcec7ae` | アカウント情報取得 | 2 | +| `InterstitialForPlayback` | `07d88886-1ec5-4115-98f2-7b6a20dbeae6` | 再生前インタースティシャル | 1 | + +#### `android14.logs.netflix.com` — ログエンドポイント + +| 項目 | 内容 | +|---|---| +| **プロトコル** | HTTPS + MSL | +| **メソッド** | POST | +| **パス** | `/log/android/logblob/1` | +| **通信内容** | クライアントログ (telemetry, FTL ステータス, NRTS サブスクリプション等) の送信 | +| **件数** | 150 件 | + +ログエントリの例: + +```json +{ + "clver": "9.57.0-63928 R android-34-EXO", + "type": "nrts", + "ftlstatus": { + "target": "primary", + "hostname": "android14.prod.ftl.netflix.com", + "targets": ["primary", "fallback-aws", "fallback-ftl-anycast"] + }, + "nrtsSubscriptionResponses": [ + {"topic": {"name": "LIVE_EVENT_STATE_CHANGE", "parameters": {"country": "JP", "videoId": 82146341}}} + ] +} +``` + +### 7.3 FTL ターゲット構成 + +| ターゲット名 | ホスト | +|---|---| +| `primary` | `android14.prod.ftl.netflix.com` | +| `fallback-aws` | `android14.prod.cloud.netflix.com` | +| `fallback-ftl-anycast` | (Anycast FTL エンドポイント) | + +### 7.4 その他のドメイン + +| ドメイン | 用途 | 復号状況 | +|---|---|---| +| `android14.push.prod.netflix.com` | プッシュ通知 (WebSocket?) | URL のみ。リクエスト/レスポンスは空 | +| `android14.ws.prod.cloud.netflix.com` | WebSocket 通信 | URL のみ。リクエスト/レスポンスは空 | +| `sessions.bugsnag.com` | クラッシュ分析セッション報告 | **取得済み** (HTTP 202 accepted) | + +### 7.5 Cronet HTTP トランスポート + +Netflix Android は **全 HTTP 通信を Chromium Cronet ネットワークスタック** (`org.chromium.net.impl.CronetUrlRequest`) 経由で行っている。OkHttp はヘッダーレベルの処理に使われるが、実際のソケット通信は Cronet が担当する。 + +`hook_cronet.js` で `CronetUrlRequest.start()` と `VersionSafeCallbacks.i` (コールバックラッパー) をフックし、全 Cronet HTTP リクエスト/レスポンスをキャプチャした結果: + +#### キャプチャ結果 + +| # | メソッド | URL | ステータス | プロトコル | 用途 | +|---|---|---|---|---|---| +| 1 | POST | `https://android14.appboot.netflix.com/appboot/NFANDROID1-PRV-P-?keyVersion=1&suspended=false` | 200 | HTTP/1.1 | Appboot (初期設定取得) | +| 2 | POST | `https://android14.prod.ftl.netflix.com/nq/androidui/samurai/~9.0.0/api` | 200 | h2 | MSL API (Samurai UI) | +| 3 | POST | `https://android14.prod.cloud.netflix.com/graphql` ×3 | 200 | h2 | GraphQL (UI データ、Cloud エンドポイント) | +| 4 | POST | `https://android14.prod.ftl.netflix.com/graphql` ×2 | 200 | h2 | GraphQL (FTL プライマリ) | +| 5 | POST | `https://android14.prod.ftl.netflix.com/nq/androidui/samurai/v1/config` | 200 | h2 | Samurai Config (Falcor) | +| 6 | POST | `https://android14.logs.netflix.com/log/android/cl/2?TAG=LOG_CLV2` ×3 | FAIL | — | クライアントログ (CLv2) — DNS 解決失敗 | + +#### ドメイン別集計 + +| ドメイン | リクエスト数 | プロトコル | 状態 | +|---|---|---|---| +| `android14.prod.ftl.netflix.com` | 4 | HTTP/2 | 全成功 | +| `android14.prod.cloud.netflix.com` | 3 | HTTP/2 | 全成功 | +| `android14.logs.netflix.com` | 3 | — | 全失敗 (`ERR_NAME_NOT_RESOLVED`) | +| `android14.appboot.netflix.com` | 1 | HTTP/1.1 | 成功 | + +#### 発見事項 + +1. **全通信が Cronet 経由**: Appboot、MSL API、GraphQL、Config、ログ送信すべてが `CronetUrlRequest.start()` を通過 +2. **HTTP/2 がデフォルト**: Appboot (HTTP/1.1) を除き、全エンドポイントで HTTP/2 を使用 +3. **MSL 暗号化は Cronet の上位**: Cronet は純粋な HTTP トランスポートを担当し、MSL の暗号化/署名は `WidevineCryptoContext` / `ApiHandlerImpl` で行われた後に Cronet に渡される +4. **新規ログエンドポイント**: `android14.logs.netflix.com/log/android/cl/2?TAG=LOG_CLV2` — CLv2 (Client Log version 2) 形式。MSL 経由の `/logblob` とは別系統 +5. **Cronet 設定**: `enableHttpCache(0, 0L)` (キャッシュ無効), `enableHttp2(true)` (HTTP/2 有効) + +#### Cronet アーキテクチャ + +```mermaid +%%{init:{'theme':'dark'}}%% +graph TB + subgraph "Netflix App" + A[ApiHandlerImpl
MSL API] -->|"MSL 暗号文"| D + B[GraphQL Client] -->|"MSL 暗号文"| D + C[AppbootHandlerImpl] -->|"平文 JSON"| D + E[Log Client
CLv2] -->|"ログデータ"| D + D[Cronet HTTP Client
CronetUrlRequest] -->|"HTTP/2 + TLS"| F[Netflix Server] + end +``` + +#### フック構成 (`hook_cronet.js`) + +| フック対象 | クラス / メソッド | 取得データ | +|---|---|---| +| リクエスト開始 | `CronetUrlRequest.start()` | URL, HTTP メソッド, UploadData 有無 | +| レスポンス受信 | `VersionSafeCallbacks.i.onResponseStarted()` | ステータスコード, プロトコル, URL | +| リダイレクト | `VersionSafeCallbacks.i.onRedirectReceived()` | リダイレクト先 URL | +| 完了 | `VersionSafeCallbacks.i.onSucceeded()` | 受信バイト数 | +| 失敗 | `VersionSafeCallbacks.i.onFailed()` | エラーメッセージ | +| キャンセル | `VersionSafeCallbacks.i.onCanceled()` | — | + +--- + +## 8. アプリ起動フロー + +```mermaid +%%{init:{'theme':'dark'}}%% +sequenceDiagram + participant App as Netflix App + participant AB as appboot.netflix.com + participant MSL as MSL Layer + participant FTL as android14.prod.ftl.netflix.com + participant Cloud as android14.prod.cloud.netflix.com + participant Logs as android14.logs.netflix.com + + rect rgba(100,100,200,0.15) + Note over App,AB: Appboot フェーズ + App->>AB: appboot リクエスト + AB->>App: TrustStore, TLS 設定, サーバー時刻 + end + + rect rgba(200,150,100,0.15) + Note over App,FTL: ALE プロビジョニング + App->>FTL: /aleProvision (RSA-OAEP-256 + A128GCM) + FTL->>App: ALE セッショントークン (JWE) + App->>App: AleService.createSession(token) + end + + rect rgba(100,200,100,0.15) + Note over App,Cloud: MSL 鍵交換 + 認証 + App->>MSL: Widevine エンティティ認証 (HMAC-SHA256 署名) + MSL->>Cloud: keyrequestdata (Widevine) + Cloud->>MSL: keyresponsedata + mastertoken + MSL->>MSL: WidevineCryptoContext 確立 + end + + rect rgba(100,100,200,0.15) + Note over App,Cloud: SSO + UI データ取得 + App->>Cloud: GraphQL RenewSSOToken + App->>Cloud: GraphQL InterstitialForLolomo + App->>Cloud: GraphQL InterstitialForProfileGate + App->>Cloud: GraphQL AccountQuery + end + + rect rgba(200,100,100,0.15) + Note over App,FTL: 再生準備 + App->>FTL: /licensedManifest (PRE_FETCH, 3 viewableIds) + App->>FTL: /license (standard, challengeBase64) + App->>FTL: bundle /events (keepAlive × 5) + end + + rect rgba(150,150,150,0.15) + Note over App,Logs: ログ送信 (並行) + App->>Logs: /logblob × 150 (telemetry, NRTS, FTL) + end +``` + +--- + +## 9. /licensedManifest リクエスト詳細 + +iOS 版の `/manifest` + `/license` が Android 版では `/licensedManifest` として統合されている。 + +### 9.1 サポートするストリームプロファイル (L3 環境) + +L3 強制環境でキャプチャされた `profiles` フィールドから。L1 環境では HEVC HDR10 (`hevc-hdr-main10-*`) や高解像度 H.264 (`playready-h264mpl40-dash`, `playready-h264hpl40-dash`) など追加のプロファイルが含まれる。 + +**動画:** + +| プロファイル | コーデック | +|---|---| +| `playready-h264mpl30-dash`, `playready-h264mpl31-dash`, `playready-h264mpl40-dash` | H.264 Main Profile | +| `playready-h264hpl22-dash`, `playready-h264hpl30-dash`, `playready-h264hpl31-dash`, `playready-h264hpl40-dash` | H.264 High Profile | +| `hevc-hdr-main10-L30-dash-cenc`, `hevc-hdr-main10-L31-dash-cenc`, `hevc-hdr-main10-L40-dash-cenc`, `hevc-hdr-main10-L41-dash-cenc` | HEVC HDR10 | +| `vp9-profile0-L21-dash-cenc`, `vp9-profile0-L30-dash-cenc`, `vp9-profile0-L31-dash-cenc`, `vp9-profile0-L40-dash-cenc` | VP9 Profile 0 | + +**音声:** + +| プロファイル | コーデック | +|---|---| +| `heaac-2-dash`, `heaac-2hq-dash` | HE-AAC | +| `xheaac-dash` | xHE-AAC (低ビットレート) | + +**字幕・その他:** + +| プロファイル | 用途 | +|---|---| +| `imsc1.1`, `simplesdh`, `nflx-cmisc` | 字幕 (IMSC 1.1, SDH) | +| `BIF320` | トリックプレイサムネイル | + +### 9.2 DRM 設定 + +```json +{ + "drmType": "widevine", + "flavor": "PRE_FETCH", + "manifestVersion": "v2", + "licenseType": "limited", + "challenges": { + "primary": [{ + "challengeBase64": "", + "drmSessionId": 1 + }] + } +} +``` + +### 9.3 iOS 版との主な違い + +| 項目 | iOS | Android | +|---|---|---| +| API パス | `/manifest` + `/license` (別リクエスト) | `/licensedManifest` (統合) | +| DRM タイプ | `fairplay` | `widevine` | +| チャレンジ形式 | FairPlay SPC | Widevine CDM protobuf | +| 動画コーデック | H.264, HEVC | H.264, HEVC HDR10, VP9 | +| Manifest バージョン | 不明 | `v2` | +| ライセンスタイプ | `limited` | `limited` (PRE_FETCH), `standard` (再生時) | + +--- + +## 10. フックスクリプト構成 + +`hook_netflix_android.js` (約 1853 行) は以下の 8 つのフック関数で構成: + +| フック関数 | 対象 | フックするクラス/メソッド | +|---|---|---| +| `hookSSLPinning()` | SSL ピン留め無効化 | `X509TrustManager`, `CertificatePinner` | +| `hookSSL()` | ネイティブ SSL 通信 | `SSL_write`, `SSL_read` (libssl.so 等) | +| `hookMSL()` | MSL プロトコル層 | `ApiHandlerImpl.apiRequest`, `BaseHandler.processRequest`, `PayloadChunk.$init`, `MessageInputStream.read`, `AppbootHandlerImpl.appbootExecute` | +| `hookMSLCrypto()` | MSL 暗号化 | `WidevineCryptoContext`, `HmacSha256Signer`, `AesCbcEncryptor`, `SymmetricCryptoContext`, `JsonWebEncryptionCryptoContext` | +| `hookHTTP()` | OkHttp 通信 | `RealCall.getResponseWithInterceptorChain`, `URL.openConnection` | +| `hookWidevineKeyExchange()` | 鍵交換 (列挙のみ) | `WidevineKeyExchange`, `DiffieHellmanExchange` 等 | +| `hookALE()` | ALE 暗号化 | `AleService`, `AleSession`, `AleCryptoBouncyCastle`, `JweBase` | +| `hookWidevineDRM()` | Widevine DRM | `android.media.MediaDrm` | + +`hook_cronet.js` — Cronet HTTP トランスポート専用フック: + +| フック関数 | 対象 | フックするクラス/メソッド | +|---|---|---| +| `hookSSLPinning()` | SSL ピン留め無効化 | `TrustManagerImpl`, `CertificatePinner` | +| `hookCronetStart()` | Cronet リクエスト開始 | `CronetUrlRequest.start()` | +| `hookCronetCallbacks()` | Cronet コールバック | `VersionSafeCallbacks.i` (onResponseStarted, onRedirectReceived, onSucceeded, onFailed, onCanceled) | + +--- + +## 11. 復号状況サマリー + +### 11.1 レイヤー別取得状況 + +| レイヤー | 取得方法 | 状態 | 件数 | 備考 | +|---|---|---|---|---| +| **MSL リクエスト平文** | `ApiHandlerImpl.apiRequest` フック | **取得済み** | 160 | 暗号化前のパラメータ JSON を完全捕捉 | +| **MSL レスポンス平文** | `BaseHandler.processRequest` フック | **未取得** | 14 (全て null) | フック発火するがフィールド抽出に失敗 | +| **Widevine encrypt 平文** | `WidevineCryptoContext.encrypt` フック | **取得済み** | 322 | CBOR エンコード済み MSL コンテンツ | +| **Widevine decrypt 平文** | `WidevineCryptoContext.c` フック | **取得済み** | 36 | CBOR バイナリ (MSL レスポンスデータ) | +| **Widevine sign データ** | `WidevineCryptoContext.b` フック | **取得済み** | 322 | MSL メッセージバイト | +| **HMAC-SHA256 sign** | `HmacSha256Signer.a` フック | **取得済み** | 1 | エンティティ認証データ | +| **Appboot レスポンス** | `AppbootHandlerImpl.appbootExecute` フック | **取得済み** | 1 | TrustStore, TLS 設定を完全捕捉 | +| **ALE プロビジョニング** | `AleService` フック | **取得済み** | 2+2 | provisionRequest + createSession | +| **ALE 暗号化** | `AleSession` + `AleCryptoBouncyCastle` フック | **取得済み** | 3 | encrypt → aesGcmEncrypt → jwe.encrypt | +| **ALE 復号** | `AleSession.decrypt` 等 | **未取得** | 0 | フック実装済みだがイベント未発生 | +| **Widevine DRM** | `android.media.MediaDrm` フック | **取得済み** | 17 | 全イベントタイプ捕捉済み | +| **HTTP (OkHttp)** | `RealCall` フック | **部分的** | 6 | Netflix 外ドメイン (Bugsnag) のみ | +| **SSL (native)** | `SSL_write` フック | **部分的** | 27 | バイナリデータのみ (HTTP/2 フレーム) | +| **Cronet HTTP** | `CronetUrlRequest.start()` + `VersionSafeCallbacks.i` | **取得済み** | 28 | 全 HTTP リクエスト/レスポンスの URL, ステータス, プロトコルを捕捉 | + +### 11.2 API パス別取得状況 + +| API パス | ドメイン | リクエスト | レスポンス | +|---|---|---|---| +| `/aleProvision` | FTL | **取得済み** | **復元済み** (L3 decrypt → CBOR → GZIP) — provisionResponse (JWE トークン) | +| `/licensedManifest` | FTL | **取得済み** | **復元済み** (L3 decrypt → CBOR → GZIP) — 456KB、CDN URL + ストリーム一覧 + DRM | +| `/license` | FTL | **取得済み** | **復元済み** (L3 decrypt → CBOR → GZIP) — licenseResponseBase64 (Widevine protobuf) | +| `/events` (bundle) | FTL | **取得済み** | **未取得** | +| `config` (Falcor) | FTL | **取得済み** | **復元済み** (L3 decrypt → CBOR → GZIP) — deviceConfig, streamingConfig2 等 | +| `/graphql` (6 ops) | Cloud | **取得済み** | **復元済み** (L3 decrypt → CBOR → GZIP) — AccountQuery 等 | +| `/logblob` | Logs | **取得済み** | **復元済み** (L3 decrypt → CBOR → GZIP) — logblob ACK | +| appboot | appboot.netflix.com | N/A (GET) | **取得済み** (直接フック) | + +--- + +## 12. 復号済みレスポンスの復元可能性 + +### 12.1 現在の状況 + +`BaseHandler.processRequest` フックによる MSL API レスポンスの直接取得は失敗している (全件 `response: null`)。これは難読化フィールド名の問題であり、L1/L3 に関係しない。 + +一方、**L3 環境では Widevine decrypt フックで復号後の平文データが取得可能**であり、CBOR デコード → GZIP 展開により MSL レスポンスの平文 JSON を完全に復元できることを確認済み。 + +> **L1 環境での制約**: L1 (TEE) では `WidevineCryptoContext.decrypt` の平文が Widevine CDM のセキュアメモリ内で処理されるため、Frida からの `plaintext_b64` 取得は不可能と推定される。レスポンス復元には `BaseHandler.processRequest` のフィールド抽出修正、または `MessageInputStream.read` の蓄積が必要。 + +### 12.2 復元パイプライン + +```mermaid +%%{init:{'theme':'dark'}}%% +flowchart LR + A["暗号化された
MSL レスポンス"] -->|WidevineCryptoContext.c| B["復号済み平文
(CBOR バイナリ)"] + B -->|"cbor2.loads()"| C["dict
key 62 = GZIP データ"] + C -->|"gzip.decompress()"| D["アプリケーション
JSON レスポンス"] + + style B stroke:#0f0,stroke-width:2px + style D stroke:#0f0,stroke-width:2px +``` + +### 12.3 CBOR PayloadChunk の構造 + +キャプチャされた `msl.widevine.decrypt` の `plaintext_b64` を CBOR デコードすると、**数値キー** の dict が得られる: + +| CBOR キー | 値 | 意味 | +|---|---|---| +| `62` | `bytes` (GZIP 圧縮データ) | **ペイロード本体** — GZIP 展開するとアプリケーション層 JSON | +| `22` | `int` (例: `1214369777020027`) | メッセージ ID | +| `44` | `"GZIP"` | 圧縮アルゴリズム | +| `63` | `true` / `false` | `endofmsg` — 最終チャンクフラグ | +| `14` | `int` (例: `1`, `2`, ...) | チャンクシーケンス番号 | + +> **注**: フィールド名が文字列 (`"data"`) ではなく数値 (`62`) になっている。これは MSL の CBOR エンコーディングが整数キーを使用するため。 + +### 12.4 復元の検証結果 + +実際にキャプチャデータで CBOR → GZIP 復元を実行し、以下のレスポンスを確認: + +| URL / API | チャンク数 | 復元サイズ | 復元内容 | +|---|---|---|---| +| `.../samurai/v1/config` | 3 | 26,953B | `deviceConfig`, `streamingConfig2`, `accountConfig` (Falcor JSON Graph) | +| `.../samurai/~9.0.0/api` | 3 | 9,208B | `AccountQuery` レスポンス — `memberSince`, `countryOfSignUp: JP` 等 | +| `/licensedManifest` | 8 | 456KB | **マニフェスト完全復元** — 3 viewableIds, video/audio/text トラック, CDN URL, DRM | +| `/license` (standard) | 1 | 1.9KB | Widevine ライセンスレスポンス — `licenseResponseBase64`, `drmGroupId`, `expiration` | +| `/aleProvision` | 1 | 1.3KB | ALE provisionResponse (JWE トークン + セッション鍵) | +| `.../log/android/logblob/1` | 10 | ~60KB/chunk | logblob ACK (サーバー確認応答) | + +復元例 (config レスポンス): +```json +{ + "paths": [["deviceConfig", "hendrixConfig", "networkScoreConfig", "accountConfig"]], + "jsonGraph": { + "accountConfig": { + "$type": "atom", + "value": { + "thumbRatingActive": true, + "videoBufferSize": 96468992, + "streamingCodecPrefData": { + "isAVCHighCodecEnabled": true, + "isVP9HWCodecEnabled": true, + "isAV1CodecEnabled": false, + "isXHEAACCodecEnabled": true + } + } + } + } +} +``` + +### 12.5 復元手順 + +```python +import cbor2, gzip, base64, json + +# 1. plaintext_b64 を Base64 デコード +plaintext = base64.b64decode(event["plaintext_b64"]) + +# 2. CBOR デコード (数値キーの dict) +chunk = cbor2.loads(plaintext) + +# 3. キー 62 の GZIP データを展開 +payload_json = gzip.decompress(chunk[62]) + +# 4. アプリケーション層 JSON を取得 +response = json.loads(payload_json) +``` + +### 12.6 復元対象の分布 + +| ドメイン | decrypt 件数 | 復元可能 | 主な内容 | +|---|---|---|---| +| `android14.prod.ftl.netflix.com` | ~8 | **6 件** | config, API レスポンス (AccountQuery 等) | +| `android14.prod.cloud.netflix.com` | ~2 | **要検証** | GraphQL レスポンス | +| `android14.logs.netflix.com` | ~26 | **10 件** | logblob ACK (大量チャンク) | + +> **結論**: `BaseHandler.processRequest` は失敗しているが、**Widevine decrypt の平文データから CBOR (key 62) → GZIP 展開で MSL レスポンスの平文 JSON が完全に復元できる**。config, AccountQuery, `/licensedManifest` (456KB), `/license` 等の全主要 API レスポンスを復元済み。復元結果は `logs/android_20260313/decrypted/` に保存。 + +--- + +## 13. レスポンス復元結果 + +### 13.1 復元済みレスポンス一覧 + +L3 Widevine decrypt → CBOR (key 62) → GZIP 展開で復元。`logs/android_20260313/decrypted/` に保存。 + +| ファイル | API パス | サイズ | 内容 | +|---|---|---|---| +| `01_config_msgid*.json` | appboot | 1.6KB | MSL TrustStore, TLS cipher suites, サーバー時刻 | +| `02_config_msgid*.json` | `/aleProvision` | 1.3KB | ALE provisionResponse (JWE トークン) | +| `03_config_msgid*.json` | config (Falcor) | 39KB | deviceConfig, streamingConfig2, accountConfig | +| `04_api_msgid*.json` | `/graphql` | 15KB | AccountQuery (memberSince, countryOfSignUp 等) | +| `05_api_msgid*.json` | `/aleProvision` | 1.3KB | ALE provisionResponse (2nd) | +| `06_logblob_msgid*.json` | `/graphql` | 15KB | AccountQuery (logs ドメイン経由) | +| `07_logblob_msgid*.json` | config | 39KB | config (logs ドメイン経由) | +| `08_logblob_msgid*.json` | `/licensedManifest` | 622KB | **マニフェスト完全復元** — 3 viewableIds, CDN URL, ストリーム一覧, DRM | +| `09_api_msgid*.json` | `/license` | 1.9KB | standard ライセンスレスポンス (licenseResponseBase64) | + +### 13.2 BaseHandler.processRequest の問題 (未解決) + +`msl.api.response` は全 14 件で `response: null, size: 0`。原因: + +- `BaseHandler.processRequest` の戻り値 `o.jRP` クラスからフィールドを抽出しようとしているが: + - フィールド `c` (byte[] レスポンスボディ) → `null` を返す + - フィールド `e` (Map レスポンスヘッダー) → フィールド未発見 +- 難読化により実際のフィールド名が異なる可能性 +- **回避策**: 方法2 (Widevine decrypt 平文の CBOR デコード) で全主要レスポンスを復元済み。`BaseHandler` の修正は優先度低。 + +### 13.3 復元パイプライン + +```python +import cbor2, gzip, base64, json + +# 1. plaintext_b64 を Base64 デコード +plaintext = base64.b64decode(event["plaintext_b64"]) + +# 2. CBOR デコード (数値キーの dict) +chunk = cbor2.loads(plaintext) + +# 3. キー 62 の GZIP データを展開 +payload_json = gzip.decompress(chunk[62]) + +# 4. アプリケーション層 JSON を取得 +response = json.loads(payload_json) +``` + +> 複数チャンクの場合は `messageid` (キー 22) でグルーピングし、`sequencenumber` (キー 14) 順に結合してから GZIP 展開する。 + +--- + +## 14. iOS 版との主要な違い + +| 観点 | iOS | Android | +|---|---|---| +| **バイナリ形式** | `MslClient.framework` (C++, Mach-O) | Java APK (ProGuard 難読化) | +| **暗号エンジン** | C++ `aesCbcEncrypt` / `signHmacSha256` | Java `WidevineCryptoContext` (TEE 委譲) | +| **DRM** | FairPlay (SPC/CKC) | Widevine CDM v17.0.0 (本キャプチャは L3) | +| **鍵交換** | Diffie-Hellman, JWE/JWK Ladder | Widevine ベース | +| **ALE** | なし | あり (RSA-OAEP-256 + A128GCM) | +| **エンコーディング** | JSON + CBOR (RapidJSON) | CBOR (CBOR self-described tag) | +| **MSL ドメイン** | `ios.prod.cloud.netflix.com` | `android14.prod.cloud.netflix.com` | +| **FTL ドメイン** | `ios.prod.ftl.netflix.com` | `android14.prod.ftl.netflix.com` | +| **ログドメイン** | (cloud と共有) | `android14.logs.netflix.com` (専用) | +| **マニフェスト API** | `/manifest` + `/license` (分離) | `/licensedManifest` (統合) | +| **動画コーデック** | H.264, HEVC | H.264, HEVC HDR10, VP9 | +| **Appboot レスポンス** | **未取得** | **取得済み** | +| **MSL レスポンス** | **未取得** | **L3: decrypt 経由で復元済み** (9 API レスポンス) / L1: 未取得 | +| **GraphQL** | cloud のリクエスト取得済み | cloud のリクエスト取得済み (Persisted Query) | +| **SSL ピン留め** | ObjC TrustManager | `CertificatePinner` + `X509TrustManager` | +| **UI フレームワーク** | Falcor + FTLProbeClient | Samurai (Tanto) + NRTS | +| **クラッシュ分析** | (未検出) | Bugsnag 6.25.0 | + +--- + +## 15. キャプチャ統計 + +### セッション: `android_20260313` + +| 項目 | 値 | +|---|---| +| 総イベント数 | ~943 ファイル | +| MSL API リクエスト | 160 | +| MSL API レスポンス | 14 (全て response=null) | +| Widevine encrypt | 322 | +| Widevine sign | 322 | +| Widevine decrypt | 36 | +| DRM イベント | 17 | +| ALE イベント | 7 | +| HTTP イベント | 6 | +| SSL write (raw) | 27 | +| Appboot レスポンス | 1 | +| ドメイン数 | 9 | + +### ドメイン別イベント数 + +| ドメイン | イベント数 | 主な通信 | +|---|---|---| +| `android14.logs.netflix.com` | 421 | logblob (150), widevine encrypt/decrypt | +| `crypto/` (ドメインなし) | 334 | widevine.sign (333), hmacSha256.sign (1) | +| `_other/` (ドメインなし) | 78 | DRM, ALE, ssl.write | +| `android14.prod.cloud.netflix.com` | 68 | GraphQL (6), widevine encrypt/decrypt | +| `android14.prod.ftl.netflix.com` | 55 | aleProvision, licensedManifest, license, bundle | +| `msl.netflix.com` | 18 | msl.api.response (全て null) | +| `appboot.netflix.com` | 1 | appboot レスポンス | +| その他 | 6 | Bugsnag, push, WebSocket | diff --git a/docs/msl_ios.md b/docs/msl_ios.md new file mode 100644 index 0000000..fb5b8dc --- /dev/null +++ b/docs/msl_ios.md @@ -0,0 +1,483 @@ +# Netflix Message Security Layer (MSL) — iOS 動的解析レポート + +Netflix iOS アプリ (`Netflix-15.48.1.ipa` / `Argo.app`) のバイナリ `MslClient.framework` を静的解析し、Frida による動的フックで得られた通信データと突合した結果をまとめる。 + +--- + +## 1. MSL プロトコル概要 + +MSL (Message Security Layer) は Netflix が独自開発した TLS 上のアプリケーション層セキュリティプロトコル。エンドポイント間の認証・鍵交換・メッセージの暗号化と完全性検証を担う。 + +### プロトコルの目的 + +- **エンティティ認証**: デバイス (ESN) をサーバーに証明する +- **ユーザー認証**: Netflix アカウントの認証 (メール/パスワード, Cookie, SSO, UserIdToken) +- **鍵交換**: セッション鍵の安全な確立 (DH, JWE/JWK Ladder 等) +- **メッセージ保護**: ペイロードの暗号化 (AES-CBC) と署名 (HMAC-SHA256) +- **トークン管理**: MasterToken / UserIdToken / ServiceToken によるセッション維持 + +--- + +## 2. アーキテクチャ + +### 2.1 フレームワーク構成 + +```mermaid +%%{init:{'theme':'dark'}}%% +graph TB + subgraph "Argo.app (Netflix iOS)" + A[Argo
メインバイナリ] --> B[MslClient.framework
MSL プロトコルコア] + A --> C[NFWebCrypto.framework
Web Crypto API] + A --> D[NFURLSession.framework
HTTP トランスポート] + A --> E[NFContentKey.framework
コンテンツ鍵管理] + A --> F[NFLicenseFetch.framework
DRM ライセンス] + A --> G[Falcor.framework
データモデル] + A --> H[FTLProbeClient.framework
低遅延プローブ] + B --> C + B --> D + F --> B + end +``` + +### 2.2 MslClient 内部クラス構成 + +```mermaid +%%{init:{'theme':'dark'}}%% +classDiagram + class IosMslClient { + -MslContext clientMslContext + -IosMslStore mslStore + -MslTrustStore mslTrustStore + -EncoderFactory encoderFactory + -MslControl mslControl + +sendAPIRequest(url, headers, params, userAuthData, options, callback) + +appboot(params) + +initializeMslControl() + +makeUserAuthData() + +makeClientRequestMessageContext() + } + + class IosMdxCryptoContext { + -AppleWebCrypto crypto + -MslEncoderFactory encoderFactory + +encrypt(data) NSData + +decrypt(data) NSData + +hmac(data) NSData + } + + class MslHttpTransaction { + -NSURLRequest request + -MslHttpRequestStream reqStream + -MslHttpResponseStream respStream + +write(data, length) + +read(data, length) + +flush() + +close() + } + + class MslUserAuthenticationData { + -NSString GUID + +isValid() bool + } + + MslEmailPasswordAuthenticationData --|> MslUserAuthenticationData + MslNetflixIdAuthenticationData --|> MslUserAuthenticationData + MslSsoTokenAuthenticationData --|> MslUserAuthenticationData + MslUserIdTokenAuthenticationData --|> MslUserAuthenticationData + MslSwitchProfileAuthenticationData --|> MslUserAuthenticationData + + IosMslClient --> IosMdxCryptoContext + IosMslClient --> MslHttpTransaction + IosMslClient --> MslUserAuthenticationData + IosMslClient --> MslContextHolder + IosMslClient --> UserIdTokenHolder + IosMslClient --> IosMslStoreTerminationListener +``` + +--- + +## 3. MSL メッセージ構造 + +### 3.1 全体構造 + +```mermaid +%%{init:{'theme':'dark'}}%% +graph LR + subgraph "MSL メッセージ (JSON or CBOR)" + H[Header] --> P1[PayloadChunk 1] + P1 --> P2[PayloadChunk 2] + P2 --> PN[PayloadChunk N
endofmsg=true] + end +``` + +Header は `MessageHeader` (正常時) または `ErrorHeader` (エラー時) のいずれか。 + +### 3.2 MessageHeader + +``` +{ + "headerdata": "", // AES-CBC で暗号化された JSON + "signature": "", + "mastertoken": { ... } // または "entityauthdata": { ... } +} +``` + +`headerdata` を復号すると以下の JSON が得られる: + +| フィールド | 型 | 説明 | +|---|---|---| +| `messageid` | number | メッセージ一意ID | +| `sender` | string | 送信者 (ESN) | +| `recipient` | string | 受信先 | +| `renewable` | boolean | トークン更新要求 | +| `handshake` | boolean | ハンドシェイクメッセージか | +| `nonreplayableid` | number | リプレイ防止ID | +| `timestamp` | number | Unix タイムスタンプ | +| `capabilities` | object | 対応する圧縮アルゴリズム等 | +| `keyrequestdata` | array | 鍵交換リクエスト | +| `keyresponsedata` | object | 鍵交換レスポンス | +| `userauthdata` | object | ユーザー認証データ | +| `useridtoken` | object | UserIdToken | + +### 3.3 MasterToken + +``` +{ + "mastertoken": { + "tokendata": "", // サーバー鍵で暗号化 + "signature": "" + } +} +``` + +`tokendata` (サーバーのみ復号可能) の内部: + +| フィールド | 説明 | +|---|---| +| `renewalwindow` | 更新可能開始時刻 | +| `expiration` | 有効期限 | +| `sequencenumber` | シーケンス番号 | +| `serialnumber` | シリアル番号 | +| `sessiondata` | 暗号化されたセッションデータ (encryptionkey, hmackey, identity) | + +### 3.4 PayloadChunk + +``` +{ + "payloadchunk": { + "payload": "", // AES-CBC + "signature": "" + } +} +``` + +`payload` を復号すると: + +| フィールド | 説明 | +|---|---| +| `messageid` | メッセージID (Header と一致) | +| `sequencenumber` | チャンク番号 | +| `endofmsg` | 最終チャンクか | +| `compressionalgo` | `GZIP` (圧縮時) | +| `data` | Base64 エンコードされたアプリケーションデータ (圧縮時は展開が必要) | + +### 3.5 ErrorHeader + +``` +{ + "errordata": "", + "signature": "" +} +``` + +--- + +## 4. 暗号スタック + +### 4.1 鍵交換フロー + +```mermaid +%%{init:{'theme':'dark'}}%% +sequenceDiagram + participant C as Client (iOS) + participant S as Server (netflix.com) + + C->>S: MessageHeader + keyrequestdata
(scheme: DIFFIE_HELLMAN) + Note over C: DH 公開鍵を生成して送信 + S->>C: MessageHeader + keyresponsedata
(mastertoken 付き) + Note over C: dhComputeSharedSecret() で共有鍵を導出 + Note over C: aesKwUnwrap() でセッション鍵を展開 + Note over C: encryptionkey + hmackey を取得 + C->>S: 以降のメッセージは
aesCbcEncrypt + signHmacSha256 で保護 +``` + +### 4.2 鍵交換スキーム + +バイナリから抽出された `keyrequestdata.scheme` の値: + +| スキーム | 説明 | +|---|---| +| `DIFFIE_HELLMAN` | DH 鍵交換。`dhComputeSharedSecret()` で共有鍵を導出 | +| `JWE_LADDER` | JWE ベースのラダー方式鍵交換 | +| `JWK_LADDER` | JWK ベースのラダー方式鍵交換 | +| `ASYMMETRIC_WRAPPED` | RSA 等の非対称鍵でセッション鍵をラップ | +| `SYMMETRIC_WRAPPED` | 対称鍵でセッション鍵をラップ | + +### 4.3 メッセージ暗号化/署名 + +```mermaid +%%{init:{'theme':'dark'}}%% +flowchart TD + A[平文 headerdata / payload] -->|AES-128-CBC| B[暗号文] + B -->|HMAC-SHA256| C[署名] + + subgraph "使用する鍵" + EK[encryptionkey
AES-128-CBC 用] + HK[hmackey
HMAC-SHA256 用] + end + + EK --> B + HK --> C + + subgraph "鍵の由来" + DH[DH 共有鍵 or
JWE/JWK Ladder] --> KW[AES Key Unwrap] + KW --> EK + KW --> HK + end +``` + +### 4.4 C++ 暗号関数 (namespace `netflix::msl::crypto`) + +| 関数 | シグネチャ | 用途 | +|---|---|---| +| `aesCbcEncrypt` | `(key, iv, plaintext) → ciphertext` | ペイロード暗号化 | +| `aesCbcDecrypt` | `(key, iv, ciphertext) → plaintext` | ペイロード復号 | +| `signHmacSha256` | `(key, data) → signature` | メッセージ署名 | +| `verifyHmacSha256` | `(key, data, signature) → bool` | 署名検証 | +| `aesKwWrap` | `(kek, key) → wrapped` | セッション鍵ラップ | +| `aesKwUnwrap` | `(kek, wrapped) → unwrapped` | セッション鍵アンラップ | +| `dhComputeSharedSecret` | `(priv, pub, prime) → shared` | DH 共有鍵導出 | +| `rsaEncrypt` | `(EVP_PKEY*, input) → output` | RSA 暗号化 | +| `rsaDecrypt` | `(EVP_PKEY*, input) → output` | RSA 復号 | +| `hmacSha` | `(alg, key, data) → mac` | 汎用 HMAC | + +引数はすべて `std::vector` (arm64 レイアウト: `+0x00` begin ptr, `+0x08` end ptr)。 + +--- + +## 5. 認証スキーム + +### 5.1 エンティティ認証 + +デバイスをサーバーに証明する。バイナリからは `NONE` スキームのみ確認。 + +### 5.2 ユーザー認証 + +```mermaid +%%{init:{'theme':'dark'}}%% +graph LR + subgraph "ユーザー認証スキーム" + EP[EMAIL_PASSWORD
メール + パスワード] + NID[NETFLIXID
NetflixId + SecureNetflixId] + UIT[USER_ID_TOKEN
既存トークンで再認証] + SSO[SSO_TOKEN
SSO トークン] + SP[SWITCH_PROFILE
プロフィール切替] + end +``` + +| ObjC クラス | スキーム | 保持データ | +|---|---|---| +| `MslEmailPasswordAuthenticationData` | `EMAIL_PASSWORD` | email, password | +| `MslNetflixIdAuthenticationData` | `NETFLIXID` | netflixId, secureNetflixId | +| `MslUserIdTokenAuthenticationData` | `USER_ID_TOKEN` | UIT (UserIdToken) | +| `MslSsoTokenAuthenticationData` | `SSO_TOKEN` | ssoToken | +| `MslSwitchProfileAuthenticationData` | `SWITCH_PROFILE` | switchGUID, originalGUID | + +--- + +## 6. 通信先ドメイン + +### 6.1 ライセンス・MSL 関連ドメイン + +#### `appboot.netflix.com` — 初回起動・エンティティ認証 + +| 項目 | 内容 | +|---|---| +| **プロトコル** | HTTPS (非 MSL) | +| **メソッド** | GET | +| **パス** | `/appboot/{ESN}?keyVersion=1` | +| **通信内容** | アプリ起動時にデバイスの ESN (例: `NFAPPL-02-IPHONE9=1-`) をパスに含めてリクエスト。レスポンスで MSL TrustStore、サーバー設定、FTL ターゲットホスト一覧を受け取る。MSL 鍵交換の前段階として必須 | +| **タイミング** | アプリ起動時 (coldstart)、および定期的な再取得 | +| **復号状況** | URL のみ取得済み (`NSURL URLWithString` フック)。リクエストボディは無い (GET)。**レスポンスボディは未取得** — `_handleAppbootResponse` のフックが未実装のため、TrustStore・設定の中身は見えていない | + +#### `ios.prod.cloud.netflix.com` — MSL API メインエンドポイント + +| 項目 | 内容 | +|---|---| +| **プロトコル** | HTTPS + MSL (アプリケーション層で MSL 暗号化) | +| **メソッド** | POST | +| **通信内容** | MSL で暗号化されたペイロードを送受信。以下の主要 API を確認: | + +MSL API (`IosMslClient.sendAPIRequest` 経由): + +| MSL パス | 用途 | 詳細 | 復号状況 | +|---|---|---|---| +| `/manifest` | **ストリーミングマニフェスト取得** | `viewableId` (コンテンツID) を指定し、利用可能なストリームプロファイル (H.264/HEVC/Audio/字幕) の一覧と CDN URL を取得。`flavor` は `PRE_FETCH` (プリフェッチ) と `standard` (再生開始時) の2種。`drmType: "fairplay"` で DRM 方式を指定 | **リクエスト平文: 取得済み** — `sendAPIRequest` フックで暗号化前の全パラメータ (viewableId, profiles, drmType 等) を捕捉。**レスポンス: 未取得** — サーバー返却の manifest JSON (CDN URL、ストリーム一覧) は `aesCbcDecrypt` で復号されるが、アプリ層での復号後データの捕捉フックが無い | +| `/license` | **DRM ライセンス取得** | FairPlay の `challengeBase64` (SPC: Server Playback Context) を送信し、CKC (Content Key Context) を受け取る。`licenseType=limited`、`playbackContextId`、`drmContextId`、`esn` をクエリパラメータで指定 | **リクエスト平文: 取得済み** — `challengeBase64` (SPC)、`drmSessionId`、`xid` 等を捕捉。SPC 自体は FairPlay が生成したバイナリの Base64 であり、中の鍵素材は取り出せていない。**レスポンス (CKC): 未取得** | +| `/logblob` | ログ送信 | クライアントログ (再生状態、エラー、ネットワーク統計等) をサーバーに送信 | **リクエスト平文: 取得済み** — ログ内容 (type, msg, ftlstatus, ネットワーク統計等) を完全に捕捉 | +| `/syncDeactivateLinks` | デバイスリンク同期 | デバイスの無効化リンクを同期 | **リクエスト平文: 取得済み** | + +HTTP API (非 MSL、GraphQL/Falcor 経由): + +| HTTP パス | 用途 | 詳細 | 復号状況 | +|---|---|---|---| +| `/graphql` | GraphQL API | ブラウズデータ、マイリスト操作等の UI データ取得。`operationName` でクエリを指定 (例: `myListActions`) | **リクエスト: 取得済み** — `setHTTPBody` フックで URL エンコードされたクエリ全文 (operationName, query, variables, esn 等) を捕捉。**レスポンス: 未取得** | +| `/msl/playapi/ios/logblob` | 再生ログ送信 | MSL ラップされた再生関連ログ (FTL ステータス、プローブ結果等) | **リクエスト: 部分的** — URL とメタデータ (size, content_type) は取得済みだが、ボディは MSL 暗号化済みのため `body: null`。平文は `/logblob` MSL API 側で捕捉済み | +| `/nq/iosplatform/pbo_license/~1.0.0/router` | **ライセンスルーター** | DRM ライセンス取得のルーティングエンドポイント | **リクエスト: 部分的** — URL とメタデータ (size: 2321) は取得済みだが、ボディは MSL 暗号化済みのため `body: null`。平文は `/license` MSL API 側で捕捉済み | + +#### `ios.prod.ftl.netflix.com` — FTL (低遅延) エンドポイント + +| 項目 | 内容 | +|---|---| +| **プロトコル** | HTTPS | +| **メソッド** | POST | +| **パス** | `/graphql` | +| **通信内容** | `ios.prod.cloud.netflix.com` と同等の GraphQL API を提供する低遅延パス。FTL (Faster Than Light) はNetflix の独自エッジネットワーク (Open Connect) 上のプロキシで、AWS 経由より近いエッジから応答する。フォールバック先として `ios.prod.cloud.netflix.com` (AWS) と `ios-anycast.prod.ftl.netflix.com` (FTL Anycast) が設定されている | +| **タイミング** | UI 操作時のデータ取得全般 | +| **復号状況** | **リクエスト: 部分的** — URL と HTTP メタデータ (method, content_type) は取得済みだが `body: null` (size: 0)。FTL 経由の GraphQL ボディは `setHTTPBody` フックで捕捉できていない (FTL 専用のリクエスト構築パスを使用している可能性)。**レスポンス: 未取得** | + +### 6.2 コンテンツ配信ドメイン + +#### `occ-*.nflxso.net` — Open Connect CDN (画像) + +| 項目 | 内容 | +|---|---| +| **プロトコル** | HTTPS | +| **メソッド** | GET | +| **パス** | `/dnm/api/v6/{apiKey}/{imageHash}.{png,jpg,webp}?r={resolution}` | +| **通信内容** | ボックスアート、サムネイル等の画像アセットをCDN から取得。`occ-0-7146-8934.1.nflxso.net` のようなパターンで、数字部分は CDN ノードを示す。大量の並列リクエストが発生 (1セッションで50件以上) | +| **復号状況** | **URL: 取得済み** — 画像 URL のフルパスとクエリパラメータを捕捉 (50件以上)。画像自体は暗号化されていない通常の HTTPS のため復号不要。**レスポンス (画像バイナリ): 未取得** — `SSL_read` フックは無効化中 | + +> **Note**: 動画・音声ストリーム本体の CDN ドメイン (`ipv4-*.1.oca.nflxvideo.net` 等) は `/manifest` レスポンスに含まれる URL から配信されるが、manifest レスポンスが未取得のためストリーム URL 自体を把握できていない。また DASH セグメントは DRM (FairPlay) で暗号化されており、コンテンツ鍵は `/license` レスポンス (CKC) から取得される。 + +### 6.3 その他 + +| ドメイン | 用途 | 通信内容 | 復号状況 | +|---|---|---|---| +| `ichnaea-web.netflix.com` | 地域判定 | `POST /cl2` (application/json) でクライアントの地理的位置を判定。コンテンツのリージョン制限に使用 | URL のみ。`body: null`、レスポンスも未取得 | + +### 6.4 復号状況サマリー + +| レイヤー | 取得方法 | 状態 | 備考 | +|---|---|---|---| +| **MSL リクエスト平文** | `IosMslClient.sendAPIRequest` フック | **取得済み** | 暗号化前のパラメータ JSON を完全捕捉 (`/manifest`, `/license`, `/logblob` 等) | +| **MSL レスポンス平文** | `aesCbcDecrypt` フック (実装済み) | **未取得** | `hookMslCrypto()` に実装済みだが `crypto/` ディレクトリにログが無い。`MslClient` モジュールがロードされていないか、C++ シンボルの解決に失敗した可能性 | +| **HTTP リクエストボディ** | `setHTTPBody` フック | **部分的** | GraphQL (cloud) は取得済み。FTL 経由・MSL ラップ済みリクエストは `body: null` | +| **HTTP レスポンスボディ** | `SSL_read` フック (無効化中) | **未取得** | `hookSSL()` がコメントアウトされているため全レスポンスが未捕捉 | +| **MSL 暗号鍵素材** | `aesKwUnwrap` / `dhComputeSharedSecret` フック | **未取得** | MslClient C++ フックからのログが出力されていない | +| **FairPlay SPC/CKC** | `/license` MSL API フック | **SPC のみ** | `challengeBase64` (SPC) は Base64 で取得済みだが中身は FairPlay バイナリ。CKC (レスポンス) は未取得 | +| **コンテンツ鍵** | `NFContentKey.framework` (未フック) | **未取得** | DRM コンテンツ鍵の取得にはさらなるフックが必要 | + +--- + +## 7. アプリ起動フロー + +```mermaid +%%{init:{'theme':'dark'}}%% +sequenceDiagram + participant App as Argo.app + participant MSL as MslClient + participant AB as appboot.netflix.com + participant API as ios.prod.cloud.netflix.com + + App->>MSL: initWithDevice:storage:urlSession:... + MSL->>MSL: initializeMslControl() + + rect rgba(100, 100, 200, 0.15) + Note over MSL,AB: Appboot フェーズ + MSL->>AB: appboot リクエスト (ESN, 設定) + AB->>MSL: appboot レスポンス (TrustStore, 設定) + MSL->>MSL: _handleAppbootResponse + MSL->>MSL: _updateEntityAuthDeviceIdToken + end + + rect rgba(100, 200, 100, 0.15) + Note over MSL,API: 鍵交換フェーズ + MSL->>API: MessageHeader (entityauthdata + keyrequestdata) + API->>MSL: MessageHeader (mastertoken + keyresponsedata) + MSL->>MSL: dhComputeSharedSecret → aesKwUnwrap + MSL->>MSL: encryptionkey + hmackey 確立 + end + + rect rgba(200, 100, 100, 0.15) + Note over MSL,API: API 通信フェーズ + App->>MSL: sendAPIRequest(url, params, userAuthData, ...) + MSL->>MSL: aesCbcEncrypt(headerdata) + signHmacSha256 + MSL->>MSL: aesCbcEncrypt(payloadchunk) + signHmacSha256 + MSL->>API: 暗号化済み MSL メッセージ (HTTP POST) + API->>MSL: 暗号化済みレスポンス + MSL->>MSL: aesCbcDecrypt + verifyHmacSha256 + MSL->>App: 平文レスポンス + end +``` + +--- + +## 8. エンコード形式 + +バイナリ内に `mslcbor_decode_` シンボルが存在することから、JSON と CBOR の両方をサポート: + +- **JSON**: デフォルトのエンコード形式。`MslEncoderFactory` + `JsonMslArray` で処理 +- **CBOR**: バイナリエンコード形式。`mslcbor_decode_negint` 等のシンボルあり。libcbor (`configuration.h.in`) を同梱 +- **JSON パーサー**: RapidJSON (Tencent 製, MIT ライセンス) を使用 (`license.txt` から確認) + +--- + +## 9. 解析に使ったツール + +| ツール | 用途 | +|---|---| +| `unzip` | IPA (ZIP) の展開 | +| `strings` | バイナリからの文字列抽出 | +| `nm --demangle` | C++ シンボルのデマングル・一覧取得 | +| Frida + Gadget | 動的フックによる実行時データ取得 | +| `hook_netflix.js` | MSL 暗号関数・API 呼び出しのインターセプト | +| `run.py` | Frida ログの JSONL パース・ドメイン別ファイル保存 | + +--- + +## 10. 未取得レスポンス一覧 + +以下は MSL 暗号化されているため現在のフック構成では平文を捕捉できていないレスポンス。いずれも `ios.prod.cloud.netflix.com` から MSL (AES-CBC + HMAC-SHA256) で暗号化されて返される。 + +| API パス | レスポンス内容 | 重要度 | 取得に必要なアプローチ | +|---|---|---|---| +| `/manifest` | ストリーミングマニフェスト JSON — CDN URL 一覧 (`urls`)、ストリーム情報 (`video_tracks`, `audio_tracks`, `timedtexttracks`)、DRM 初期化データ (`drmHeader`)、セッション情報 (`playbackContextId`) 等 | **高** | MSL レスポンス復号の捕捉が必要 | +| `/license` | FairPlay CKC (Content Key Context) — `licenseResponseBase64` として返される。コンテンツ復号鍵が含まれる | **高** | MSL レスポンス復号 + FairPlay CKC パース | +| `/logblob` | ログ送信の ACK — ステータスコード程度 | 低 | — | +| `/syncDeactivateLinks` | デバイス無効化リンクの同期結果 | 低 | — | +| `/nq/iosplatform/pbo_license/~1.0.0/router` | ライセンスルーティング結果 — 実際の `/license` エンドポイントへのリダイレクト情報 | 中 | MSL レスポンス復号 | +| `/graphql` (cloud) | GraphQL レスポンス JSON — UI データ (lolomo、ビデオ詳細、マイリスト等) | 中 | `SSL_read` フック有効化 or レスポンスコールバックのフック | +| `/graphql` (ftl) | 同上 (FTL 経由) | 中 | 同上 | +| `/appboot/{ESN}` | Appboot 設定 JSON — TrustStore、FTL ターゲット、機能フラグ等 | 中 | `SSL_read` フック有効化 or `_handleAppbootResponse` のフック | + +### 復号に向けたアプローチ + +``` +方法1: hookSSL() を有効化して SSL_read をフック + → HTTP レスポンスを丸ごと取得 + → MSL 暗号化されたレスポンスはバイナリ (Base64 JSON) として見える + → 暗号化された headerdata / payload をそのまま保存 + +方法2: MslClient C++ の aesCbcDecrypt フックを修正 + → hookMslCrypto() は実装済みだが MslClient モジュールのロード検知に問題あり + → Module.findModuleByName("MslClient") の戻り値を確認 + → MslClient.framework 内の aesCbcDecrypt で復号後の平文を直接取得 + +方法3: ObjC コールバック層でレスポンスを捕捉 + → IosMslClient の sendAPIRequest の callback 引数をフック + → MSL 復号後の平文 JSON がコールバックに渡されるため最も確実 + → 実装: ObjC.Block(args[7]) の invocation をラップして引数をログ +``` + +**最も効果的な手法**: 方法3 (コールバックフック) が MSL 復号済みの平文を直接取得でき、暗号鍵の管理が不要。`/manifest` と `/license` のレスポンスを取得できれば、CDN URL 一覧と FairPlay CKC を入手できる。 diff --git a/docs/pxa_esn.md b/docs/pxa_esn.md new file mode 100644 index 0000000..3cb551b --- /dev/null +++ b/docs/pxa_esn.md @@ -0,0 +1,196 @@ +# PXA ESN (Proxy ESN) — 取得フロー + +> **対象:** Netflix Android v9.57.0 (build 63928) +> **取得方法:** Frida フック (`hook_msl.js` → `ProxyEsnMslRequest` / `ProxyEsn`) +> **検証日:** 2026-03-14 + +--- + +## 概要 + +PXA ESN はサーバーが発行するデバイス識別子。ローカル生成の Base ESN (PRV) と異なり、サーバー側で fingerprint を付与して返す。MSL 通信や GraphQL API で `X-Netflix.esn` / `X-Netflix-ProxyEsn` ヘッダーとして使用される。 + +--- + +## 取得フロー + +### 1. トリガー + +`WidevineEntityAuthEsnProviderImpl.c(Long serialNumber)` が呼ばれたとき、以下の条件で `getProxyEsn` が発行される: + +```java +// WidevineEntityAuthEsnProviderImpl.c() +if (serialNumber == null) return true; // serialNumber なし → 再取得 +if (proxyEsn.c != serialNumber) return true; // serialNumber 不一致 → 再取得 +return proxyEsn.d; // expired フラグ → true なら再取得 +``` + +### 2. キャッシュ有効期限 + +`ProxyEsn` コンストラクタで有効期限を判定: + +```java +// ProxyEsn.$init() +long ttl = this.f; // EsnHendrixConfig.refreshProxyEsnTimeInMs +if (ttl < 1) { + this.d = false; // expired = false → 無期限キャッシュ + return; +} +boolean expired = C20986jOb.c(ttl, this.g); // TTL ベースの期限判定 +this.d = expired; +``` + +**実測値: `refreshProxyEsnTimeInMs = 0`** + +TTL が 0 のため、**PXA ESN は一度取得したら無期限にキャッシュされる**。`getProxyEsn` が再発行されるのは以下のケースのみ: + +- 初回インストール時 +- アプリデータクリア時 +- SharedPreferences が消えたとき +- `masterTokenSerialNumber` が変わったとき + +### 3. MSL リクエスト + +`ProxyEsnMslRequest` が MSL 経由で `/getProxyEsn` にリクエストを送信する。 + +**リクエストボディ:** + +```json +{"url": "/getProxyEsn"} +``` + +**リクエストヘッダー:** + +MSL 層で暗号化されるため、HTTP レベルでのヘッダーは空。MSL VolleyRequest のヘッダーとして `router: getProxyEsn` が設定される。 + +```java +// ProxyEsnMslRequest.getHeaders() +C20979jNv.e(d, "router", "getProxyEsn", true, true); +d.remove("x-netflix.nq-shadow.id"); +d.remove("x-netflix.nq-shadow"); +``` + +### 4. MSL レスポンス + +サーバー (`playapi`) が PXA ESN を JSON で返す。 + +**レスポンス:** + +```json +{ + "id": 1, + "version": 2, + "serverTime": 1773478742949, + "result": { + "esn": "NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-02028KVLM5OU1MSBUS4RUV6011TPPJVL5CS65GVMMH6PD5I6TSR8TMAC49OTSG3I4BJ3P78GD7ECQ6CVIIFHPN52CC20RJ2CPEE0A3FF" + }, + "common": {}, + "from": "playapi" +} +``` + +| フィールド | 説明 | +|---|---| +| `id` | リクエスト ID | +| `version` | プロトコルバージョン | +| `serverTime` | サーバー時刻 (epoch ms) | +| `result.esn` | PXA ESN 本体 | +| `from` | 発行元サーバー (`playapi`) | + +- Cookie ではない。MSL レスポンスの JSON ボディで返される。 +- fingerprint 部分はサーバー側で生成されるため、ローカルでは再現不可。 +- **起動ごとに異なる fingerprint が返される** (同一デバイスでも毎回異なる)。 + +### 5. 保存 + +`ProxyEsn.onKnown(Long serialNumber, String esn)` で SharedPreferences に永続化。 + +```java +// ProxyEsn.onKnown() +this.b = esn; +jNK.b(this.e, "nf_drm_esn", esn); // PXA ESN 文字列 +this.g = System.currentTimeMillis(); +this.c = serialNumber; + +JSONObject json = new JSONObject(); +json.put("esn", this.b); +json.put("ts", this.g); // 取得時刻 +json.put("sn", this.c); // masterTokenSerialNumber +jNK.b(this.e, "nf_drm_proxy_esn", json.toString()); +``` + +**SharedPreferences キー:** + +| キー | 値 | 例 | +|---|---|---| +| `nf_drm_esn` | PXA ESN 文字列 | `NFANDROID1-PXA-P-L3-GOOGLPIXEL=...` | +| `nf_drm_proxy_esn` | JSON メタデータ | `{"esn":"...","ts":1773478742949,"sn":8572399748193023}` | + +### 6. 使用 + +取得後、以下のヘッダーで API リクエストに付与される: + +| ヘッダー | 用途 | +|---|---| +| `X-Netflix.esn` | GraphQL / Cronet リクエスト | +| `X-Netflix-ProxyEsn` | WebSocket / MSL リクエスト | + +--- + +## PXA ESN 構造 + +``` +NFANDROID1-PXA-P-L3-GOOGLPIXEL=4A==5G=-22594-02028KVLM5OU1MSB... +│ │ │ │ │ │ └─ サーバー発行 fingerprint (毎回異なる) +│ │ │ │ │ └─ Widevine systemId +│ │ │ │ └─ sanitized model (Base ESN と同じ) +│ │ │ └─ Security Level (L3) +│ │ └─ Device Category (P=Phone) +│ └─ Type: PXA = Proxy (サーバー発行) +└─ Platform prefix +``` + +--- + +## タイムライン (実測) + +``` +08:41:58.240 ProxyEsn.$init() expired=true (キャッシュなし or 期限切れ) +08:41:58.240 getHeaders() router=getProxyEsn +08:41:58.248 getBodyForNq() {"url":"/getProxyEsn"} + ── MSL リクエスト送信 ── +08:41:59.567 onSuccess() レスポンス受信 (約1.3秒後) +08:41:59.569 ProxyEsn.onKnown() SharedPreferences に保存 (2ms後) + ── 以後のAPIリクエストで PXA ESN を使用 ── +``` + +--- + +## 関連クラス + +| クラス | 役割 | +|---|---| +| `ProxyEsnMslRequest` | `/getProxyEsn` MSL リクエスト発行 | +| `ProxyEsn` | PXA ESN のキャッシュ管理・永続化 | +| `WidevineEntityAuthEsnProviderImpl` | ESN プロバイダー (PRV/PXA 統合管理) | +| `EsnHendrixConfig` (`o.fkQ`) | `refreshProxyEsnTimeInMs` 設定値 | +| `InterfaceC13214fkS` | `onKnown` コールバック | + +--- + +## キャプチャ方法 + +`hook_msl.js` で以下のフックが有効: + +| フック | イベント名 | 内容 | +|---|---|---| +| `ProxyEsn.$init` | `proxyEsn.forceExpired` | expired を強制 true にして再取得を発火 | +| `ProxyEsnMslRequest.getBodyForNq` | `proxyEsn.request` | リクエストボディ | +| `ProxyEsnMslRequest.getHeaders` | `proxyEsn.requestHeaders` | リクエストヘッダー | +| `ProxyEsnMslRequest.onSuccess` | `proxyEsn.response` | レスポンス JSON | +| `ProxyEsnMslRequest.e` | `proxyEsn.error` | エラー | +| `ProxyEsn.onKnown` | `proxyEsn.onKnown` | 保存される ESN と serialNumber | + +```bash +./run_android.sh hook_msl.js +``` diff --git a/docs/userauthdata_android.md b/docs/userauthdata_android.md new file mode 100644 index 0000000..7c8b137 --- /dev/null +++ b/docs/userauthdata_android.md @@ -0,0 +1,544 @@ +# Netflix MSL UserAuthData — Android 動的解析レポート + +Netflix Android アプリ (`com.netflix.mediaclient` v9.57.0) の MSL 通信におけるユーザー認証データ (`userauthdata`) の生成・送信メカニズムを、Frida フックおよびキャプチャ済み暗号平文の CBOR 解析により明らかにした結果をまとめる。 + +--- + +## 1. 結論サマリー + +| 項目 | 内容 | +|---|---| +| **userauthdata の送信タイミング** | **初回認証時のみ 1 回** (鍵交換 + エンティティ認証) | +| **使用される認証スキーム** | `NETFLIXID` (netflixId + secureNetflixId) | +| **通常リクエストの認証方式** | `UserIdToken` (key 18) — サーバー発行のトークン | +| **CBOR キー** | userauthdata = **key 47**, useridtoken = **key 18** | +| **apiRequest 第5引数** | 常に `null` — userauthdata は MslControl 層で MSL MessageHeader に直接埋め込まれ、apiRequest パラメータとしては渡されない | + +--- + +## 2. ユーザー認証フロー + +```mermaid +%%{init:{'theme':'dark'}}%% +sequenceDiagram + participant App as Netflix App + participant MSL as MslControl + participant WV as WidevineCryptoContext + participant Server as Netflix Server + + rect rgba(200,100,100,0.15) + Note over App,Server: 初回認証 (1回のみ) + App->>MSL: apiRequest(url, body, headers, userId, null) + Note over MSL: MslControl が MessageHeader を構築 + MSL->>MSL: userauthdata を MessageHeader に埋め込み
(CBOR key 47) + Note over MSL: scheme: NETFLIXID
authdata: {netflixId, secureNetflixId} + MSL->>WV: encrypt(headerdata) + MSL->>Server: MSL Message (暗号化済み) + Server->>MSL: keyresponsedata + mastertoken
+ UserIdToken + end + + rect rgba(100,200,100,0.15) + Note over App,Server: 以降のリクエスト (348回観測) + App->>MSL: apiRequest(url, body, headers, userId, null) + Note over MSL: MslControl が MessageHeader を構築 + MSL->>MSL: UserIdToken を MessageHeader に埋め込み
(CBOR key 18) + MSL->>WV: encrypt(headerdata) + MSL->>Server: MSL Message (暗号化済み) + end +``` + +--- + +## 3. キャプチャデータによる証明 + +### 3.1 MSL MessageHeader の認証キー分布 + +353 件の暗号化前 MSL MessageHeader (Widevine encrypt 平文) を CBOR 解析した結果: + +| 認証方式 | CBOR キー | 件数 | 割合 | +|---|---|---|---| +| **UserIdToken のみ** | key 18 | 348 | 98.6% | +| **userauthdata のみ** (NETFLIXID) | key 47 | 1 | 0.3% | +| **どちらもなし** (鍵交換 handshake) | — | 5 | 1.1% | +| **両方** | key 18 + 47 | 0 | 0% | + +### 3.2 userauthdata が送信されたヘッダー (1件) + +ファイル: `logs/android_20260313/android14.prod.ftl.netflix.com/0052_msl.widevine.encrypt.json` + +``` +MSL MessageHeader CBOR キー: + [17, 19, 20, 21, 22, 24, 36, 40, 41, 42, 47] + 17: mastertoken (list) + 19: renewable = false + 20: sender = "" (ESN) + 21: handshake = true ← 鍵交換ハンドシェイク + 22: messageid = 2491352839960294 + 24: timestamp = 1773392548 + 40: peer = true + 41: nonreplayable_id = 10 + 42: keyrequestdata (WIDEVINE) + 47: userauthdata ← ★ ここにユーザー認証データ +``` + +### 3.3 userauthdata の構造 (CBOR key 47) + +```json +{ + "30": "NETFLIXID", + "35": { + "56": "", + "60": "" + } +} +``` + +| CBOR キー | フィールド名 | 値 | +|---|---|---| +| `30` | `scheme` | `NETFLIXID` | +| `35` | `authdata` | (dict) | +| `35.56` | `netflixId` | `v=3&mac=AQEAEQABABQxZyPp7r91RNj4pmPTIatYcL3jjIUVbvQ.&dt=1773392547767` | +| `35.60` | `secureNetflixId` | `v=3&ct=<暗号化トークン 672文字>&pg=ZEULH5S2GNGCRAABCSG6J2EGGA&ch=` | + +### 3.4 netflixId の構造 + +``` +v=3&mac=AQEAEQABABQxZyPp7r91RNj4pmPTIatYcL3jjIUVbvQ.&dt=1773392547767 +``` + +| パラメータ | 値 | 意味 | +|---|---|---| +| `v` | `3` | バージョン | +| `mac` | `AQEAEQABABQxZyPp7r91RNj4pmPTIatYcL3jjIUVbvQ.` | HMAC 署名 (改ざん検知) | +| `dt` | `1773392547767` | デバイスタイムスタンプ (Unix ms) | + +### 3.5 secureNetflixId の構造 + +``` +v=3&ct=<暗号化トークン>&pg=ZEULH5S2GNGCRAABCSG6J2EGGA&ch= +``` + +| パラメータ | 値 | 意味 | +|---|---|---| +| `v` | `3` | バージョン | +| `ct` | `BgjHlOvcAxLcA5Nne...` (672 文字) | 暗号化された認証トークン (protobuf base64url) | +| `pg` | `ZEULH5S2GNGCRAABCSG6J2EGGA` | プロファイル GUID | +| `ch` | `AQEAEAABABTp79nN9l_2MuRhqTXl0-SjAqcm83QU8vw.` | チャネル HMAC | + +--- + +## 4. UserIdToken の構造 (通常リクエスト) + +初回認証後、サーバーから発行された `UserIdToken` が以降の全リクエストで使用される。 + +### 4.1 CBOR 構造 (key 18) + +```json +{ + "15": "", + "16": "" +} +``` + +### 4.2 tokendata の CBOR 構造 + +| CBOR キー | フィールド名 | 値 (キャプチャ例) | +|---|---|---| +| `29` | `userdata` | `` (暗号化されたユーザーデータ) | +| `25` | `serialnumber` | `5970945423176003` | +| `11` | `issuedate` | `1773420422` (2026-03-13 16:47:02 UTC) | +| `12` | `mtserialnum` | `8079134327147185` (紐づく MasterToken のシリアル) | +| `profileid` | `profileid` | `ZEULH5S2GNGCRAABCSG6J2EGGA` | +| `13` | `expiration` | `1774601222` (2026-03-27 08:47:02 UTC, **14日後**) | + +### 4.3 トークンライフサイクル + +``` +発行: 2026-03-13 16:47:02 UTC +有効期限: 2026-03-27 08:47:02 UTC +有効期間: 14日間 +``` + +同一セッション内で全 348 リクエストが同じ UserIdToken (serialnumber: `5970945423176003`) を使用。 + +--- + +## 5. 認証スキーム一覧 + +Netflix MSL は 5 つのユーザー認証スキームをサポート (iOS ドキュメントから): + +| スキーム | クラス (Android) | 送信データ | 用途 | +|---|---|---|---| +| **`NETFLIXID`** | `com.netflix.msl.userauth.NetflixIdAuthenticationData` (ProGuard 難読化) | netflixId, secureNetflixId | **本キャプチャで観測** — Cookie ベース認証 | +| `EMAIL_PASSWORD` | ProGuard 難読化 (ClassNotFound) | email, password | ログインフォーム認証 | +| `USER_ID_TOKEN` | `com.netflix.msl.userauth.UserIdTokenAuthenticationData` | 既存 UserIdToken | トークン再認証 | +| `SSO_TOKEN` | ProGuard 難読化 (ClassNotFound) | ssoToken | SSO 認証 | +| `SWITCH_PROFILE` | ProGuard 難読化 (ClassNotFound) | switchGUID, originalGUID | プロファイル切替 | + +### 5.1 ProGuard 難読化の影響 + +| クラス | 状態 | +|---|---| +| `UserAuthenticationData` (基底クラス) | **存在確認済み** — メソッド `a(2)`, `b(2)`, `c(2)` に難読化 | +| `UserIdTokenAuthenticationData` | **存在確認済み** — コンストラクタフック成功 (ただし呼び出しなし) | +| `EmailPasswordAuthenticationData` | ClassNotFoundException | +| `NetflixIdAuthenticationData` | ClassNotFoundException | +| `SsoTokenAuthenticationData` | ClassNotFoundException | +| `SwitchProfileAuthenticationData` | ClassNotFoundException | + +→ 4 つのサブクラスが ProGuard で難読化/インライン化されている。`UserIdTokenAuthenticationData` のみ元のクラス名が残存。 + +--- + +## 6. MSL CBOR 整数キーマッピング (固定値) + +MSL プロトコルは JSON と CBOR の 2 つのエンコーディングをサポートする。CBOR エンコード時は、JSON の文字列キーが**プロトコル仕様で定義された固定の整数キー**に置換される。これはメッセージサイズ削減のための最適化であり、Netflix の MSL ライブラリ (`MslEncoderFactory`) にハードコードされている。 + +以下のマッピングは、353 件の暗号化前 MSL MessageHeader と 403 件の PayloadChunk の CBOR 解析、および iOS 版 MSL ドキュメント (`msl_ios.md` §3) の JSON フィールド名との照合により確定したものである。 + +### 6.1 MSL メッセージ外殻 (Header / MasterToken / EntityAuthData) + +JSON 構造: +```json +{ + "headerdata": "", + "signature": "", + "mastertoken": { "tokendata": "...", "signature": "..." } +} +``` + +| CBOR キー | JSON フィールド名 | 型 | 説明 | 観測値例 | +|---|---|---|---|---| +| `15` | `tokendata` | bytes | トークンデータ (CBOR エンコード、暗号化済み) | `` | +| `16` | `signature` | bytes | HMAC 署名 | `` (HMAC-SHA256) | + +> **注**: key 15/16 は MasterToken, UserIdToken, keyrequestdata 等の**トークン系構造体で共通使用**される汎用キー。 + +### 6.2 MessageHeader (復号後の headerdata) + +JSON 構造 (iOS 版 `msl_ios.md` §3.2): +```json +{ + "messageid": 12345, + "sender": "NFANDROID1-PRV-P-...", + "renewable": false, + "handshake": false, + "timestamp": 1773392401, + "capabilities": { ... }, + "useridtoken": { "tokendata": "...", "signature": "..." }, + "userauthdata": { "scheme": "NETFLIXID", "authdata": { ... } }, + "keyrequestdata": [ ... ], + "nonreplayableid": 10 +} +``` + +| CBOR キー | JSON フィールド名 | 型 | 説明 | 観測数 | +|---|---|---|---|---| +| `17` | `mastertoken` | list[dict{15,16}] | MasterToken (tokendata + signature) | 18 件 | +| `18` | `useridtoken` | dict{15,16} | UserIdToken (tokendata + signature) | 348 件 | +| `19` | `renewable` | bool | トークン更新可能フラグ | 353 件 (全ヘッダー) | +| `20` | `sender` | string | 送信者 ESN (空文字列 = デフォルト) | 353 件 | +| `21` | `handshake` | bool | ハンドシェイクメッセージフラグ | 353 件 | +| `22` | `messageid` | int | メッセージ一意 ID | 353 件 | +| `24` | `timestamp` | int | Unix タイムスタンプ (秒) | 353 件 | +| `36` | `capabilities` | dict | クライアント能力情報 | 353 件 | +| `40` | `peer` | bool | ピアツーピアフラグ | 353 件 | +| `41` | `nonreplayableid` | int | リプレイ防止 ID | 2 件 | +| `42` | `keyrequestdata` | list[dict{30,31}] | 鍵交換リクエストデータ | 3 件 | +| `47` | `userauthdata` | dict{30,35} | **ユーザー認証データ** | 1 件 | + +### 6.3 capabilities (key 36 の内部) + +| CBOR キー | JSON フィールド名 | 型 | 説明 | 観測値 | +|---|---|---|---|---| +| `37` | `compressionalgos` | list[string] | サポート圧縮アルゴリズム | `[]` (空) | +| `38` | `languages` | list[string] | サポート言語 | `["GZIP", "LZW"]` | +| `39` | `encoders` | list[string] | サポートエンコーダー | `["CBOR"]` | +| `maxpayloadchunksize` | `maxpayloadchunksize` | int | 最大ペイロードチャンクサイズ | `-1` (無制限) | + +> **注**: `maxpayloadchunksize` は文字列キーのまま。Netflix 独自拡張のため整数キーが未割当と推定。 + +### 6.4 keyrequestdata / keyresponsedata (key 42 の内部要素) + +| CBOR キー | JSON フィールド名 | 型 | 説明 | 観測値 | +|---|---|---|---|---| +| `30` | `scheme` | string | 鍵交換スキーム | `"WIDEVINE"` | +| `31` | `keydata` | dict | 鍵データ | Widevine デバイス証明書 | +| `31.duid` | `keydata.duid` | bytes | デバイス一意 ID (32B) | Widevine deviceUniqueId | +| `31.50` | `keydata.cdmsg` | bytes | CDM メッセージ (protobuf) | Widevine ライセンスチャレンジ (~2577B) | + +### 6.5 userauthdata (key 47 の内部) + +| CBOR キー | JSON フィールド名 | 型 | 説明 | 観測値 | +|---|---|---|---|---| +| `30` | `scheme` | string | 認証スキーム | `"NETFLIXID"` | +| `35` | `authdata` | dict | スキーム固有の認証データ | dict{56, 60} | + +#### NETFLIXID スキームの authdata (key 35 の内部) + +| CBOR キー | JSON フィールド名 | 型 | 説明 | +|---|---|---|---| +| `56` | `netflixid` | string | NetflixId Cookie (URL エンコード) | +| `60` | `securenetflixid` | string | SecureNetflixId Cookie (URL エンコード) | + +### 6.6 MasterToken tokendata (key 17[].15 を CBOR デコード) + +| CBOR キー | JSON フィールド名 | 型 | 説明 | 観測値例 | +|---|---|---|---|---| +| `25` | `serialnumber` | int | MasterToken シリアル番号 | `5970945423176003` | +| `26` | `renewable` | bool | 更新可能フラグ | `true` | +| `27` | `issuer` | string | 発行者 | `"sf"`, `"cad"` | +| `28` | `identity` | bytes | エンティティ ID (暗号化済み) | `` | +| `43` | `sequencenumber` | int | シーケンス番号 | `8079134327147185` | + +### 6.7 UserIdToken tokendata (key 18.15 を CBOR デコード) + +| CBOR キー | JSON フィールド名 | 型 | 説明 | 観測値例 | +|---|---|---|---|---| +| `11` | `issuedate` | int | 発行日 (Unix タイムスタンプ秒) | `1773420422` | +| `12` | `mtserialnum` | int | 紐づく MasterToken のシリアル番号 | `8079134327147185` | +| `13` | `expiration` | int | 有効期限 (Unix タイムスタンプ秒) | `1774601222` | +| `25` | `serialnumber` | int | UserIdToken シリアル番号 | `5970945423176003` | +| `29` | `userdata` | bytes | 暗号化されたユーザーデータ | `` | +| `profileid` | `profileid` | string | プロファイル GUID | `"ZEULH5S2GNGCRAABCSG6J2EGGA"` | + +> **注**: `profileid` は文字列キーのまま。Netflix 独自拡張フィールド。 + +### 6.8 PayloadChunk (暗号化前) + +| CBOR キー | JSON フィールド名 | 型 | 説明 | 観測値例 | +|---|---|---|---|---| +| `14` | `sequencenumber` | int | チャンクシーケンス番号 | `1`, `2`, ... `8` | +| `22` | `messageid` | int | メッセージ ID (MessageHeader と一致) | `1170337128274032` | +| `44` | `compressionalgo` | string | 圧縮アルゴリズム | `"GZIP"` | +| `62` | `data` | bytes | ペイロードデータ (GZIP 圧縮) | `` | +| `63` | `endofmsg` | bool | 最終チャンクフラグ | `true` / `false` | + +### 6.9 全整数キー一覧 (ソート順) + +キャプチャデータから観測された全 28 整数キーの完全一覧: + +| CBOR キー | JSON フィールド名 | 使用コンテキスト | +|---|---|---| +| `11` | `issuedate` | UserIdToken.tokendata | +| `12` | `mtserialnum` | UserIdToken.tokendata | +| `13` | `expiration` | UserIdToken.tokendata | +| `14` | `sequencenumber` | PayloadChunk | +| `15` | `tokendata` | MasterToken, UserIdToken, keyrequestdata (共通) | +| `16` | `signature` | MasterToken, UserIdToken, keyrequestdata (共通) | +| `17` | `mastertoken` | MessageHeader | +| `18` | `useridtoken` | MessageHeader | +| `19` | `renewable` | MessageHeader | +| `20` | `sender` | MessageHeader | +| `21` | `handshake` | MessageHeader | +| `22` | `messageid` | MessageHeader, PayloadChunk | +| `24` | `timestamp` | MessageHeader | +| `25` | `serialnumber` | MasterToken.tokendata, UserIdToken.tokendata | +| `26` | `renewable` | MasterToken.tokendata | +| `27` | `issuer` | MasterToken.tokendata | +| `28` | `identity` | MasterToken.tokendata | +| `29` | `userdata` | UserIdToken.tokendata | +| `30` | `scheme` | keyrequestdata, userauthdata | +| `31` | `keydata` | keyrequestdata | +| `35` | `authdata` | userauthdata | +| `36` | `capabilities` | MessageHeader | +| `37` | `compressionalgos` | capabilities | +| `38` | `languages` | capabilities | +| `39` | `encoders` | capabilities | +| `40` | `peer` | MessageHeader | +| `41` | `nonreplayableid` | MessageHeader | +| `42` | `keyrequestdata` | MessageHeader | +| `43` | `sequencenumber` | MasterToken.tokendata | +| `44` | `compressionalgo` | PayloadChunk | +| `47` | `userauthdata` | MessageHeader | +| `50` | `cdmsg` | keyrequestdata.keydata (Widevine) | +| `56` | `netflixid` | userauthdata.authdata (NETFLIXID) | +| `60` | `securenetflixid` | userauthdata.authdata (NETFLIXID) | +| `62` | `data` | PayloadChunk | +| `63` | `endofmsg` | PayloadChunk | + +### 6.10 文字列キー (整数マッピングなし) + +| キー | 使用コンテキスト | 説明 | +|---|---|---| +| `maxpayloadchunksize` | capabilities | 最大ペイロードチャンクサイズ | +| `profileid` | UserIdToken.tokendata | プロファイル GUID | +| `duid` | keyrequestdata.keydata | デバイス一意 ID | + +> これらは Netflix 独自の拡張フィールドであり、MSL 標準の整数キーが割り当てられていないと推定される。 + +--- + +## 7. userauthdata の生成メカニズム + +### 7.1 生成レイヤー + +`userauthdata` は `apiRequest` のパラメータとしてではなく、**MslControl 層で MSL MessageHeader に直接埋め込まれる**: + +``` +Netflix App + ↓ apiRequest(url, body, headers, userId=GUID, auth=null) +ApiHandlerImpl + ↓ (auth は null のまま渡される) +MslControl + ↓ MessageHeader を構築 + ↓ ← ここで userauthdata を設定 + ↓ (初回: NETFLIXID スキーム) + ↓ (以降: UserIdToken を使用) +WidevineCryptoContext.encrypt(headerdata) + ↓ +暗号化された MSL メッセージ → サーバー送信 +``` + +### 7.2 netflixId / secureNetflixId の生成元 + +これらの値は **Android の Cookie ストレージ** から取得される: + +| Cookie 名 | 生成元 | 保存場所 | +|---|---|---| +| `netflixId` (=`NetflixId`) | サーバーが Set-Cookie で発行 | Android CookieManager / SharedPreferences | +| `secureNetflixId` (=`SecureNetflixId`) | サーバーが Set-Cookie で発行 (Secure 属性) | Android CookieManager / SharedPreferences | + +ログイン成功時にサーバーからこれらの Cookie が発行され、MSL ユーザー認証に使用される。 + +### 7.3 RenewSSOToken との関係 + +キャプチャで `RenewSSOToken` GraphQL オペレーションが観測されている: + +```json +{ + "operationName": "RenewSSOToken", + "variables": { + "ssoToken": "BgiHtuvcAxL4AWLUqSE7W14gsbHZBDGMle4StHvRnr70..." + } +} +``` + +これは MSL レベルの `userauthdata` とは別の、**GraphQL レベルの SSO トークン更新**。MSL 層では既に UserIdToken で認証されており、SSO トークン更新は MSL ペイロード内で行われる。 + +--- + +## 8. 認証フロー全体図 + +```mermaid +%%{init:{'theme':'dark'}}%% +sequenceDiagram + participant User as ユーザー + participant App as Netflix App + participant MSL as MslControl + participant Server as Netflix Server + + rect rgba(255,200,200,0.15) + Note over User,Server: Phase 1: ログイン (本キャプチャ前) + User->>App: メールアドレス + パスワード入力 + App->>Server: HTTPS POST /login + Server->>App: Set-Cookie: NetflixId, SecureNetflixId + Note over App: Cookie をローカルに保存 + end + + rect rgba(200,200,255,0.15) + Note over User,Server: Phase 2: MSL 初回認証 (キャプチャ済み, 1回) + App->>MSL: apiRequest(..., userId=GUID, auth=null) + MSL->>MSL: MessageHeader 構築 + Note over MSL: key 42: keyrequestdata (WIDEVINE)
key 47: userauthdata (NETFLIXID)
→ netflixId + secureNetflixId + MSL->>Server: 暗号化 MSL メッセージ + Server->>MSL: keyresponsedata + MasterToken
+ UserIdToken (有効期間 14日) + Note over MSL: UserIdToken をメモリに保持 + end + + rect rgba(200,255,200,0.15) + Note over User,Server: Phase 3: 通常リクエスト (348回観測) + App->>MSL: apiRequest(..., userId=GUID, auth=null) + MSL->>MSL: MessageHeader 構築 + Note over MSL: key 18: useridtoken
→ serialnumber + profileid + expiration + MSL->>Server: 暗号化 MSL メッセージ + Server->>MSL: MSL レスポンス + end + + rect rgba(255,255,200,0.15) + Note over User,Server: Phase 4: トークン更新 (15回観測) + Note over MSL: MasterToken + keyrequestdata で
トークンを更新 (key 17 + key 18) + MSL->>Server: 更新リクエスト + Server->>MSL: 新しい MasterToken + end +``` + +--- + +## 9. iOS 版との比較 + +| 観点 | Android | iOS | +|---|---|---| +| **userauthdata スキーム** | `NETFLIXID` (netflixId + secureNetflixId) | `NETFLIXID` (同一) | +| **CBOR キー** | 整数キー (47, 18, etc.) | 整数キー (同一仕様) | +| **apiRequest パラメータ** | 第5引数 `UserAuthenticationData auth` = null | `makeUserAuthData()` メソッドで生成 | +| **userauthdata 設定レイヤー** | MslControl (Java) | IosMslClient (C++) | +| **暗号化** | WidevineCryptoContext | AES-CBC (C++) | +| **UserIdToken 有効期間** | 14 日 | 不明 | +| **ProGuard 影響** | サブクラス 4/5 が難読化で ClassNotFound | N/A (C++ バイナリ) | + +--- + +## 10. 解析に使用したコード + +### 10.1 CBOR デコードスクリプト + +```python +import base64, json, cbor2 +from urllib.parse import unquote + +# Widevine encrypt 平文から MSL MessageHeader を解析 +with open("logs/android_20260313/android14.prod.ftl.netflix.com/0052_msl.widevine.encrypt.json") as f: + data = json.load(f) + +raw = base64.b64decode(data["plaintext_b64"]) +header = cbor2.loads(raw) + +# userauthdata (key 47) を取得 +if 47 in header: + uad = header[47] + scheme = uad[30] # "NETFLIXID" + authdata = uad[35] + netflix_id = unquote(authdata[56]) + secure_netflix_id = unquote(authdata[60]) + print(f"scheme: {scheme}") + print(f"netflixId: {netflix_id}") + print(f"secureNetflixId: {secure_netflix_id}") + +# UserIdToken (key 18) を取得 +if 18 in header: + uit = header[18] + tokendata = cbor2.loads(uit[15]) + print(f"profileid: {tokendata.get('profileid')}") + print(f"serialnumber: {tokendata.get(25)}") + print(f"expiration: {tokendata.get(13)}") +``` + +### 10.2 Frida フックによるキャプチャ + +`hook_netflix_android.js` に以下のフックを実装: + +| フック対象 | 目的 | 結果 | +|---|---|---| +| `ApiHandlerImpl.apiRequest` 第5引数 | UserAuthenticationData オブジェクト取得 | **常に null** (MslControl 層で設定されるため) | +| `UserIdTokenAuthenticationData.$init` | コンストラクタ監視 | **フック成功、呼び出しなし** (トークンはサーバーから受信) | +| `EmailPasswordAuthenticationData.$init` | コンストラクタ監視 | ClassNotFoundException (ProGuard) | +| `NetflixIdAuthenticationData.$init` | コンストラクタ監視 | ClassNotFoundException (ProGuard) | +| `SsoTokenAuthenticationData.$init` | コンストラクタ監視 | ClassNotFoundException (ProGuard) | +| `SwitchProfileAuthenticationData.$init` | コンストラクタ監視 | ClassNotFoundException (ProGuard) | +| `UserAuthenticationData.getScheme` | スキーム名取得 | メソッド名が難読化 (ProGuard) | +| `WidevineCryptoContext.encrypt` | 暗号化前平文取得 | **成功** — CBOR 解析で userauthdata 発見 | + +--- + +## 11. まとめ + +1. **`userauthdata` は MSL MessageHeader の CBOR key 47 として暗号化前に埋め込まれる**。`apiRequest` のパラメータ(第5引数)としては渡されず、`MslControl` 層が Cookie ストレージから `netflixId` / `secureNetflixId` を読み取って設定する。 + +2. **NETFLIXID スキーム**が使用され、`netflixId` (HMAC 付きデバイストークン) と `secureNetflixId` (暗号化認証トークン + プロファイル GUID) の 2 つの値で構成される。 + +3. **初回の MSL ハンドシェイク時に 1 回だけ送信**される。サーバーは応答として `UserIdToken` (有効期間 14 日) を発行し、以降の全リクエストはこの `UserIdToken` (key 18) で認証される。 + +4. **353 件の MSL ヘッダーのうち userauthdata を含むのは 1 件のみ** (0.3%)。残りの 98.6% は `UserIdToken` による認証。 + +5. ProGuard 難読化により `NetflixIdAuthenticationData` 等のサブクラスは直接フックできないが、**Widevine encrypt の平文データを CBOR 解析することで userauthdata の完全な内容を取得できた**。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..89fe529 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "frida-objection-wrapper" +version = "0.1.0" +description = "Frida and Objection wrapper for mobile application security testing." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "asn1crypto>=1.5.1", + "bcrypt>=5.0.0", + "cffi>=2.0.0", + "colorama>=0.4.6", + "cryptography>=46.0.5", + "enum34>=1.1.10", + "frida>=17.8.0", + "frida-tools>=14.6.1", + "idna>=3.11", + "ipaddress>=1.0.23", + "objection>=1.12.3", + "paramiko>=4.0.0", + "prompt-toolkit>=3.0.52", + "pyasn1>=0.6.2", + "pycparser>=3.0", + "pygments>=2.19.2", + "pynacl>=1.6.2", + "python-dotenv>=1.2.2", + "scp>=0.15.0", + "six>=1.17.0", + "tqdm>=4.67.3", + "wcwidth>=0.6.0", + "protobuf>=3.19.0,<3.21.0", + "pycryptodome>=3.20.0", + "cbor2>=5.8.0", +] diff --git a/run.py b/run.py new file mode 100644 index 0000000..3a787b6 --- /dev/null +++ b/run.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +"""Netflix Frida Hook Runner with domain-based log organization. + +Uses frida CLI subprocess (ObjC runtime works correctly with CLI). +Parses @@LOG@@ lines and saves to domain-based directory structure: + logs// + / + _.json + crypto/ + _.json + capture.jsonl (all entries, flat) + console.log + +Usage: + python run.py [script] # iOS (default) + python run.py --android [script] # Android (spawn mode) +""" + +import argparse +import json +import os +import re +import signal +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from urllib.parse import urlparse, parse_qs, unquote + +from dotenv import load_dotenv + +load_dotenv() + +LOG_PREFIX = "@@LOG@@" + + +def get_pid(host: str, bundle_id: str) -> int: + """Get Netflix PID from frida-ps.""" + result = subprocess.run( + ["frida-ps", "-H", host, "-a"], + capture_output=True, text=True, timeout=10, + ) + for line in result.stdout.splitlines(): + if bundle_id in line: + return int(line.split()[0]) + return 0 + + +def sanitize(name: str) -> str: + """Sanitize filename component.""" + return re.sub(r'[^\w.\-]', '_', name)[:80] + + +def deep_parse_json(obj): + """Recursively parse JSON strings embedded in dicts/lists.""" + if isinstance(obj, str): + s = obj.strip() + if s.startswith(("{", "[")): + try: + return deep_parse_json(json.loads(s)) + except (json.JSONDecodeError, TypeError): + pass + return obj + if isinstance(obj, dict): + return {k: deep_parse_json(v) for k, v in obj.items()} + if isinstance(obj, list): + return [deep_parse_json(v) for v in obj] + return obj + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Netflix Frida Hook Runner") + parser.add_argument("--android", action="store_true", + help="Target Android device (spawn mode)") + parser.add_argument("script", nargs="?", default=None, + help="Frida script to inject") + return parser.parse_args() + + +def _export_cookies_and_headers(capture_file: Path, session_dir: Path): + """capture.jsonl から cookies.txt と headers.txt を生成.""" + if not capture_file.exists(): + return + + all_cookies: dict[str, str] = {} + all_headers: dict[str, str] = {} + credentials: dict[str, str] = {} + + # cred イベント → cookie 名のマッピング + cred_map = { + "cred.netflixId": "NetflixId", + "cred.secureNetflixId": "SecureNetflixId", + "cred.nfvdid": "nfvdid", + } + + with open(capture_file) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + event = entry.get("event", "") + + if event == "http.headers": + for k, v in entry.get("cookies", {}).items(): + if v and (k not in all_cookies or len(v) > len(all_cookies[k])): + all_cookies[k] = v + for k, v in entry.get("headers", {}).items(): + if k not in all_headers: + all_headers[k] = v + elif event in cred_map: + val = entry.get("value", "") + cookie_name = cred_map[event] + if val and (cookie_name not in credentials or len(val) > len(credentials[cookie_name])): + credentials[cookie_name] = val + except (json.JSONDecodeError, TypeError): + continue + + # cred で取得した値を cookies にマージ (cred の方が完全な値) + for k, v in credentials.items(): + if v and (k not in all_cookies or len(v) > len(all_cookies[k])): + all_cookies[k] = v + + if all_cookies: + cookies_file = session_dir / "cookies.txt" + with open(cookies_file, "w") as f: + for k, v in sorted(all_cookies.items()): + f.write(f"{k}={v}\n") + print(f"[*] Exported {len(all_cookies)} cookies → {cookies_file}") + + if all_headers: + headers_file = session_dir / "headers.txt" + with open(headers_file, "w") as f: + for k, v in sorted(all_headers.items()): + f.write(f"{k}: {v}\n") + print(f"[*] Exported {len(all_headers)} headers → {headers_file}") + + +def main(): + args = parse_args() + + if args.android: + host = os.getenv("ANDROID_HOST", "192.168.0.36") + bundle_id = "com.netflix.mediaclient" + script = args.script or "hook_netflix_android.js" + platform = "android" + else: + host = os.getenv("IOS_HOST", "192.168.0.34") + bundle_id = "com.netflix.Netflix" + script = args.script or "hook_netflix.js" + platform = "ios" + + if args.android: + # Android: spawn mode (-f) + print(f"[*] Platform: Android (spawn)") + print(f"[*] Host: {host}") + print(f"[*] Target: {bundle_id}") + print(f"[*] Script: {script}") + cmd = ["frida", "-f", bundle_id, "-l", script, "-H", host] + else: + # iOS: attach to running process (-p) + pid = get_pid(host, bundle_id) + if not pid: + print(f"Netflix is not running on {host}") + print("Please launch Netflix on the device first.") + sys.exit(1) + print(f"[*] Platform: iOS (attach)") + print(f"[*] Found Netflix (PID: {pid})") + print(f"[*] Script: {script}") + cmd = ["frida", "-p", str(pid), "-l", script, "-H", host] + + session_id = datetime.now().strftime("%Y%m%d") + session_dir = Path("logs") / f"{platform}_{session_id}" + session_dir.mkdir(parents=True, exist_ok=True) + + capture_file = session_dir / "capture.jsonl" + console_file = session_dir / "console.log" + + # 前回のログを削除 + for f in (capture_file, console_file): + if f.exists(): + f.unlink() + print(f"[*] Deleted {f}") + + print(f"[*] Session: {session_dir}") + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + def shutdown(sig, frame): + print("\n[*] Shutting down...") + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + + signal.signal(signal.SIGINT, shutdown) + signal.signal(signal.SIGTERM, shutdown) + + seq = 0 + domain_counts: dict[str, int] = {} + log_count = 0 + + # MSL 平文キュー: msl.api の params を domain 別に蓄積し、 + # 直後の http.request に紐付ける + msl_plaintext_queue: dict[str, list[dict]] = {} # domain → [{params, url, ...}, ...] + + try: + for line in proc.stdout: + line = line.rstrip("\n") + + if line.startswith(LOG_PREFIX): + json_str = line[len(LOG_PREFIX):] + try: + entry = json.loads(json_str) + except json.JSONDecodeError: + print(line) + continue + + # Parse query parameters from URL + url = entry.get("url") or "" + if "?" in url: + parsed = urlparse(url) + qs = parse_qs(parsed.query, keep_blank_values=True) + # Flatten single-value lists + queries = {} + for k, v in qs.items(): + val = v[0] if len(v) == 1 else v + queries[k] = deep_parse_json(val) + entry["path"] = parsed.path + entry["queries"] = queries + + # Save to flat JSONL + with open(capture_file, "a") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + log_count += 1 + seq += 1 + + event = entry.get("event", "unknown") + domain = entry.get("domain") or "" + + # MSL 平文の紐付け + if event in ("msl.api", "msl.apiRequest"): + # 平文パラメータをキューに蓄積 + # フック側のフィールド名は body または params + msl_body = entry.get("body") or entry.get("params") + msl_plaintext_queue.setdefault(domain, []).append({ + "params": msl_body, + "url": entry.get("url"), + "headers": entry.get("headers"), + "userId": entry.get("userId"), + "userauthdata": entry.get("userauthdata"), + }) + elif event == "http.request" and domain: + # MSL 暗号化されたリクエストのみ平文を紐付け + req_headers = entry.get("headers") or {} + content_enc = (req_headers.get("Content-Encoding") + or req_headers.get("content-encoding") or "") + if "msl" in content_enc.lower(): + queue = msl_plaintext_queue.get(domain, []) + if queue: + msl_info = queue.pop(0) + msl_params = msl_info.get("params") + if msl_params: + # body を復号済み MSL ペイロードに置換 + try: + entry["body"] = json.loads(msl_params) if isinstance(msl_params, str) else msl_params + except (json.JSONDecodeError, TypeError): + entry["body"] = msl_params + entry["body_source"] = "msl_decrypted" + entry["msl_url"] = msl_info.get("url") + if not queue: + del msl_plaintext_queue[domain] + + # Determine save directory (domain + URL path segments) + if domain: + save_dir = session_dir / sanitize(domain) + # URL パスに基づくネストされたディレクトリ構造 + entry_url = entry.get("url") or "" + if entry_url: + try: + parsed_url = urlparse(entry_url) + path_segs = [s for s in parsed_url.path.strip("/").split("/") if s] + for seg in path_segs: + save_dir = save_dir / sanitize(seg) + except Exception: + pass + elif event.startswith("msl."): + save_dir = session_dir / "crypto" + else: + save_dir = session_dir / "_other" + + save_dir.mkdir(parents=True, exist_ok=True) + + # Track per-path sequence + path_key = str(save_dir.relative_to(session_dir)) + domain_counts[path_key] = domain_counts.get(path_key, 0) + 1 + dseq = domain_counts[path_key] + + # Save individual file per event + domain_dir = save_dir + + body = entry.get("body") or entry.get("params") + + # Extract API path from MSL request/response for filename + api_label = "" + if event in ("msl.api", "msl.apiRequest") and body: + try: + body_obj = json.loads(body) if isinstance(body, str) else body + op = body_obj.get("operationName") or "" + inner_url = body_obj.get("url") or "" + if op: + api_label = f"_{sanitize(op)}" + elif inner_url: + api_label = f"_{sanitize(inner_url.strip('/'))}" + except (json.JSONDecodeError, TypeError): + pass + elif event == "msl.api.response": + # url comes from x-originating-url header (set by hook) + resp_url = entry.get("url") or "" + if resp_url: + resp_parsed = urlparse(resp_url) + resp_path = resp_parsed.path.strip("/") + if resp_path: + api_label = f"_{sanitize(resp_path)}" + + base = f"{dseq:04d}_{sanitize(event)}{api_label}" + response_str = entry.get("response") + data_hex = entry.get("data_hex") + has_b64 = any(k.endswith("_b64") for k in entry) + + # msl.api.response: responseフィールドをパースして個別保存 + if event == "msl.api.response" and response_str: + # メタデータ (response以外) を保存 + meta = {k: v for k, v in entry.items() if k != "response"} + try: + resp_json = deep_parse_json(json.loads(response_str)) + # パース済みレスポンスを保存 + with open(domain_dir / f"{base}.json", "w") as f: + json.dump({**meta, "response": resp_json}, f, ensure_ascii=False, indent=2) + except (json.JSONDecodeError, TypeError): + # パース失敗 — そのまま保存 + with open(domain_dir / f"{base}.json", "w") as f: + json.dump(deep_parse_json(entry), f, ensure_ascii=False, indent=2) + elif has_b64: + # Crypto event with base64 fields — save as single JSON + with open(domain_dir / f"{base}.json", "w") as f: + json.dump(deep_parse_json(entry), f, ensure_ascii=False, indent=2) + elif body is not None: + # Try to parse body as JSON + try: + body_json = json.loads(body) if isinstance(body, str) else body + body_json = deep_parse_json(body_json) + # Preserve metadata (event, ts, domain, url, etc.) alongside parsed body + meta = {k: v for k, v in entry.items() if k not in ("body", "params")} + out = {**meta, "body": body_json} + with open(domain_dir / f"{base}.json", "w") as f: + json.dump(out, f, ensure_ascii=False, indent=2) + except (json.JSONDecodeError, TypeError): + # Not JSON — save as text + with open(domain_dir / f"{base}.txt", "w") as f: + f.write(body if isinstance(body, str) else str(body)) + elif data_hex: + # Binary data — save as .bin + metadata .json + with open(domain_dir / f"{base}.bin", "wb") as f: + f.write(bytes.fromhex(data_hex)) + meta = {k: v for k, v in entry.items() if k != "data_hex"} + with open(domain_dir / f"{base}.meta.json", "w") as f: + json.dump(meta, f, ensure_ascii=False, indent=2) + else: + # URL or other metadata — save as .json + with open(domain_dir / f"{base}.json", "w") as f: + json.dump(deep_parse_json(entry), f, ensure_ascii=False, indent=2) + + # Console summary (curl-like) + method = entry.get("method", "") + url_disp = entry.get("url", "") + size = entry.get("data_size") or entry.get("size", "") + content_type = entry.get("content_type", "") + + if event == "http.request": + # curl風: > POST https://... (1234B, application/json) + ct_str = f", {content_type}" if content_type else "" + size_str = f" ({size}B{ct_str})" if size else "" + print(f" > {method} {url_disp}{size_str}") + elif event in ("msl.api", "msl.apiRequest"): + body_size = entry.get("body_size", 0) + print(f" > MSL {url_disp} ({body_size}B)") + elif event == "msl.api.response": + resp = entry.get("response", "") or "" + err = entry.get("error") + resp_len = len(resp) + preview = (resp[:160] + "...") if resp_len > 160 else resp + err_str = f" ERROR: {err}" if err else "" + print(f" < MSL {url_disp} ({resp_len}B){err_str}") + if preview: + print(f" {preview}") + elif event == "appboot.response": + resp = entry.get("response", "") or "" + resp_len = len(resp) + preview = (resp[:160] + "...") if resp_len > 160 else resp + err = entry.get("error") + err_str = f" ERROR: {err}" if err else "" + print(f" < APPBOOT ({resp_len}B){err_str}") + if preview: + print(f" {preview}") + elif event == "http.response": + status = entry.get("status", 0) + resp_size = entry.get("size", 0) + err = entry.get("error") + body = entry.get("body", "") or "" + preview = (body[:160] + "...") if len(body) > 160 else body + err_str = f" ERROR: {err}" if err else "" + print(f" < {status} {url_disp} ({resp_size}B){err_str}") + if preview: + print(f" {preview}") + elif event == "url": + print(f" * {url_disp}") + elif event == "cronet.request": + req_hdrs = entry.get("requestHeaders", {}) + ct = req_hdrs.get("Content-Type", req_hdrs.get("content-type", "")) + ct_str = f", {ct}" if ct else "" + print(f" > {method} {url_disp}{ct_str}") + elif event == "cronet.complete": + status = entry.get("statusCode", "?") + proto = entry.get("protocol", "?") + body_size = entry.get("bodySize", 0) + err = entry.get("error") + req_id = entry.get("reqId", "?") + err_str = f" ERROR: {err}" if err else "" + body_preview = "" + body_text = entry.get("body", "") + if body_text: + bp = body_text[:160] + if len(body_text) > 160: + bp += "..." + body_preview = f"\n {bp}" + print(f" < {status} {proto} {method} {url_disp} ({body_size}B) [{req_id}]{err_str}{body_preview}") + elif event == "cronet.redirect": + new_url = entry.get("newUrl", "?") + status = entry.get("statusCode", "?") + print(f" → {status} {url_disp} → {new_url}") + elif event.startswith("msl."): + size_str = f" ({size}B)" if size else "" + print(f" [{path_key}] {event}{size_str}") + else: + size_str = f" ({size}B)" if size else "" + url_short = url_disp[:80] + "..." if len(url_disp) > 80 else url_disp + detail = f" {url_short}" if url_disp else "" + print(f" [{path_key}] {event}{size_str}{detail}") + + else: + # Regular console output — skip frida banner noise + if line.startswith((" ", " . . .", "Attaching", "Spawned")): + continue + print(line) + with open(console_file, "a") as f: + f.write(line + "\n") + + except Exception as e: + print(f"[!] {e}") + finally: + proc.wait() + print(f"\n[*] {log_count} entries across {len(domain_counts)} domains") + for d, c in sorted(domain_counts.items(), key=lambda x: -x[1]): + print(f" {d}: {c}") + + # キャプチャから cookies.txt と headers.txt を自動生成 + _export_cookies_and_headers(capture_file, session_dir) + + print(f"[*] Session: {session_dir}") + + +if __name__ == "__main__": + main() diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..9e6089c --- /dev/null +++ b/run.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail + +source .env + +BUNDLE_ID="com.netflix.Netflix" +SCRIPT="${1:-hook_netflix.js}" + +# Get PID of Netflix from remote device +HOST="${IOS_HOST:-192.168.0.34}" +PID=$(frida-ps -H "$HOST" -a 2>/dev/null | grep "$BUNDLE_ID" | awk '{print $1}') + +if [ -z "$PID" ]; then + echo "Netflix is not running on $HOST" + echo "Please launch Netflix on the device first." + exit 1 +fi + +echo "Found Netflix (PID: $PID) on $HOST" +echo "Attaching with script: $SCRIPT" + +frida -p "$PID" -l "$SCRIPT" -H "$HOST" diff --git a/run_android.sh b/run_android.sh new file mode 100755 index 0000000..d6d2c91 --- /dev/null +++ b/run_android.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -euo pipefail + +source .env + +SCRIPT="${1:-hook_netflix_android.js}" + +uv run python run.py --android "$SCRIPT" diff --git a/run_cronet.py b/run_cronet.py new file mode 100644 index 0000000..315533a --- /dev/null +++ b/run_cronet.py @@ -0,0 +1,670 @@ +#!/usr/bin/env python3 +"""Netflix Cronet HTTP キャプチャ — Frida Python API. + +hook_cronet.js を Frida Python API で起動し、 +リクエスト/レスポンスを紐付けて保存する。 + +Architecture: + JS (hook_cronet.js) Python (このファイル) + ────────────────── ──────────────────── + CronetUrlRequest.start() on_message(req) + → send({type:"req",...}) → pending[reqId] に保存 + + onSucceeded on_message(resp) + → send({type:"resp",...}) → pending[reqId] と結合 + → .md ファイルに保存 + → capture.jsonl に追記 + onFailed + → send({type:"resp", 同上 (error フィールド付き) + error:...}) + +Output: + logs/android_YYYYMMDD/cronet/ + {domain}/ + {path_seg1}/ + {path_seg2}/ + 0001.md ← Request + Response 統合 (Proxyman 風) + 0001_req_plain.json ← MSL 復号済リクエスト (アプリ層 JSON) + 0001_resp_plain.json← MSL 復号済レスポンス (CBOR→GZIP→JSON) + capture.jsonl ← 全イベント (raw) + +Usage: + python run_cronet.py # デフォルト hook_cronet.js + python run_cronet.py my_hook.js # カスタムスクリプト + Ctrl+C で停止 +""" + +from __future__ import annotations + +import base64 +import gzip +import json +import os +import re +import signal +import sys +import threading +import time +from datetime import datetime +from pathlib import Path +from urllib.parse import urlparse + +import cbor2 + +import frida +import frida_tools +from dotenv import load_dotenv + +load_dotenv() + +# ─── Helpers ───────────────────────────────────────────── + +def sanitize(name: str, max_len: int = 80) -> str: + """ファイル名に使えない文字を _ に置換.""" + return re.sub(r"[^\w.\-]", "_", name)[:max_len] + + +def load_java_bridge() -> str: + """frida-tools の Java ブリッジソースを読み込む. + + create_script() は素の JS ランタイムを作成するため、 + Java グローバルが存在しない。frida CLI が使用するのと同じ + ブリッジファイルを先頭に付加して Java API を有効化する。 + """ + bridges_dir = Path(frida_tools.__file__).parent / "bridges" + java_js = bridges_dir / "java.js" + if not java_js.exists(): + raise FileNotFoundError(f"Java bridge not found: {java_js}") + source = java_js.read_text(encoding="utf-8") + # java.js は `var bridge=function(){...}();` 形式。 + # globalThis.Java に割り当てる。 + return ( + source + "\n" + "Object.defineProperty(globalThis, 'Java', {" + " value: bridge, writable: false, configurable: true" + "});\n" + ) + + +def fmt_size(n: int | str) -> str: + """バイト数を人間可読に.""" + n = int(n) if n else 0 + if n < 1024: + return f"{n}B" + if n < 1024 * 1024: + return f"{n / 1024:.1f}KB" + return f"{n / (1024 * 1024):.1f}MB" + + +def is_text_content(ct: str) -> bool: + """Content-Type がテキスト系か.""" + return any(t in ct for t in ("json", "text", "xml", "html", "javascript", + "x-www-form-urlencoded")) + + +def pretty_json(text: str) -> str | None: + """JSON 文字列を整形。失敗時は None.""" + try: + return json.dumps(json.loads(text), ensure_ascii=False, indent=2) + except (json.JSONDecodeError, TypeError): + return None + + +# ─── Core ──────────────────────────────────────────────── + +class CronetCapture: + """Frida Python API を使った Cronet HTTP キャプチャ.""" + + # 無視するドメイン(コンソール出力・ファイル保存ともにスキップ) + _IGNORE_DOMAINS: set[str] = { + "android14.logs.netflix.com", + } + + def __init__(self, host: str, bundle_id: str, script_path: str): + self.host = host + self.bundle_id = bundle_id + self.script_path = script_path + + # セッションディレクトリ + session_id = datetime.now().strftime("%Y%m%d") + self.session_dir = Path("logs") / f"android_{session_id}" + self.session_dir.mkdir(parents=True, exist_ok=True) + self.capture_file = self.session_dir / "capture.jsonl" + + # Frida ハンドル + self._device: frida.core.Device | None = None + self._session: frida.core.Session | None = None + self._script: frida.core.Script | None = None + self._pid: int | None = None + + # リクエスト追跡 + self._pending: dict[str, dict] = {} # reqId → request info + self._seq = 0 # グローバル連番 + self._domain_seq: dict[str, int] = {} # domain → 連番 + + # エラー重複抑制 + self._error_counts: dict[str, int] = {} # url_pattern → count + + # MSL 平文トラッキング + self._msl_req_queue: dict[str, list[bytes]] = {} # url_pattern → [body, ...] + self._msl_last_save: dict | None = None # {dir, base, url} + self._msl_decrypt_buf: list[bytes] = [] # 復号チャンク蓄積 + + # 統計 + self._stats = {"requests": 0, "responses": 0, "errors": 0, "bytes": 0, + "msl_decrypted": 0} + + # 停止イベント + self._stop = threading.Event() + + # ─── ライフサイクル ─── + + def start(self): + """デバイスに接続し、アプリを起動してキャプチャを開始.""" + print(f"[*] Connecting to {self.host}...") + mgr = frida.get_device_manager() + self._device = mgr.add_remote_device(self.host) + + print(f"[*] Spawning {self.bundle_id}...") + self._pid = self._device.spawn([self.bundle_id]) + + print(f"[*] Attaching to PID {self._pid}...") + self._session = self._device.attach(self._pid) + self._session.on("detached", self._on_detached) + + print(f"[*] Resuming app (waiting for Java VM)...") + self._device.resume(self._pid) + time.sleep(2) + + print(f"[*] Loading Java bridge + {self.script_path}...") + bridge = load_java_bridge() + user_source = Path(self.script_path).read_text() + source = bridge + "\n" + user_source + self._script = self._session.create_script(source, runtime="v8") + self._script.on("message", self._on_message) + self._script.load() + + print(f"[*] Saving → {self.session_dir}") + print(f"[*] Press Ctrl+C to stop\n") + + # メインスレッドをブロック + self._stop.wait() + + def stop(self): + """クリーンシャットダウン.""" + if self._stop.is_set(): + return + self._stop.set() + + print(f"\n[*] Stopping...") + try: + if self._script: + self._script.unload() + except Exception: + pass + try: + if self._session: + self._session.detach() + except Exception: + pass + try: + if self._device and self._pid: + self._device.kill(self._pid) + except Exception: + pass + + self._print_summary() + + def _on_detached(self, reason, crash): + print(f"\n[*] Detached: {reason}") + if crash: + print(f"[!] Crash: {crash}") + self._stop.set() + + # ─── メッセージハンドラ ─── + + def _on_message(self, message: dict, data: bytes | None): + msg_type = message.get("type") + + if msg_type == "send": + self._handle_event(message["payload"], data) + elif msg_type == "log": + level = message.get("level", "info") + text = message.get("payload", "") + if level == "error": + print(f" [!] {text}", file=sys.stderr) + else: + print(f" {text}") + elif msg_type == "error": + desc = message.get("description", "") + print(f" [!] Script error: {desc}", file=sys.stderr) + + def _handle_event(self, payload: dict, data: bytes | None): + evt = payload.get("type") + domain = payload.get("domain", "") + + # 無視リストに該当するドメインは完全スキップ + if any(domain.endswith(d) for d in self._IGNORE_DOMAINS): + return + + if evt == "req": + self._on_request(payload) + elif evt == "resp": + self._on_response(payload) + elif evt == "redirect": + self._on_redirect(payload) + elif evt == "msl_req": + self._on_msl_request(payload) + elif evt == "msl_decrypt": + self._on_msl_decrypt(payload) + + # capture.jsonl に全イベントを記録 + self._append_jsonl(payload) + + # ─── イベント処理 ─── + + def _on_request(self, p: dict): + req_id = p["reqId"] + self._pending[req_id] = { + "reqId": req_id, + "url": p.get("url"), + "method": p.get("method", "GET"), + "domain": p.get("domain", "unknown"), + "headers": p.get("headers", {}), + "hasBody": p.get("hasBody", False), + "req_body_b64": p.get("req_body_b64"), + "ts": p.get("ts"), + } + self._stats["requests"] += 1 + + method = p.get("method", "?") + url = p.get("url", "?") + ct = (p.get("headers") or {}).get("Content-Type", + (p.get("headers") or {}).get("content-type", "")) + enc = (p.get("headers") or {}).get("Content-Encoding", + (p.get("headers") or {}).get("content-encoding", "")) + detail = f" [{enc}]" if enc else (f" ({ct})" if ct else "") + print(f" -> {method} {url}{detail}") + + def _on_response(self, p: dict): + req_id = p["reqId"] + req = self._pending.pop(req_id, None) + + if not req: + # req が届く前に resp が来た場合のフォールバック + req = { + "reqId": req_id, + "url": p.get("url", "unknown"), + "method": p.get("method", "?"), + "domain": p.get("domain", "unknown"), + "headers": {}, + "ts": p.get("ts"), + } + + # ボディをデコード + body_bytes: bytes | None = None + if p.get("body_b64"): + try: + body_bytes = base64.b64decode(p["body_b64"]) + except Exception: + pass + + status = p.get("statusCode") + error = p.get("error") + proto = p.get("protocol", "?") + body_size = len(body_bytes) if body_bytes else int(p.get("bodySize", 0) or 0) + resp_headers = p.get("responseHeaders") or {} + + # cURL ファイルに保存 + self._seq += 1 + self._save_request( + seq=self._seq, + req=req, + status=status, + status_text=p.get("statusText", ""), + protocol=proto, + resp_headers=resp_headers, + body=body_bytes, + body_size=body_size, + error=error, + ) + + # コンソール出力 + if error: + self._stats["errors"] += 1 + url_pat = self._url_pattern(req.get("url", "")) + self._error_counts[url_pat] = self._error_counts.get(url_pat, 0) + 1 + cnt = self._error_counts[url_pat] + if cnt <= 3: + print(f" ✗ {req['method']} {req['url']}") + print(f" {error}") + elif cnt == 4: + print(f" (以降同一パターンのエラーは省略)") + else: + self._stats["responses"] += 1 + self._stats["bytes"] += body_size + body_preview = "" + if body_bytes and body_size < 4096: + ct = resp_headers.get("content-type", "") + if is_text_content(ct): + try: + text = body_bytes.decode("utf-8") + bp = text[:120] + if len(text) > 120: + bp += "..." + body_preview = f"\n {bp}" + except UnicodeDecodeError: + pass + print(f" <- {status} {req['method']} {req['url']}" + f" ({fmt_size(body_size)}){body_preview}") + + def _on_redirect(self, p: dict): + print(f" ~> {p.get('statusCode')} {p.get('url')} -> {p.get('newUrl')}") + + # ─── MSL 平文キャプチャ ─── + + def _on_msl_request(self, p: dict): + """ApiHandlerImpl.apiRequest からの MSL リクエスト平文.""" + url = p.get("url", "?") + body_b64 = p.get("body_b64") + body = base64.b64decode(body_b64) if body_b64 else None + size = len(body) if body else 0 + + url_key = self._url_pattern(url) + self._msl_req_queue.setdefault(url_key, []).append(body) + + print(f" \U0001f513 MSL req plaintext {url} ({fmt_size(size)})") + + def _on_msl_decrypt(self, p: dict): + """WidevineCryptoContext.c からの復号済み MSL ペイロードチャンク.""" + b64 = p.get("plaintext_b64") + if not b64: + return + plaintext = base64.b64decode(b64) + + # CBOR デコード → key 62 (GZIP データ) → 展開 + try: + chunk = cbor2.loads(plaintext) + except Exception: + # CBOR デコード失敗時は生データを保存 + self._msl_decrypt_buf.append(plaintext) + return + + end_of_msg = chunk.get(63, False) + compressed = chunk.get(62) # GZIP 圧縮済みペイロード + + if compressed: + try: + decompressed = gzip.decompress(compressed) + self._msl_decrypt_buf.append(decompressed) + except Exception: + # GZIP 展開失敗 → 生データ + self._msl_decrypt_buf.append( + compressed if isinstance(compressed, bytes) else plaintext + ) + + if end_of_msg and self._msl_decrypt_buf: + combined = b"".join(self._msl_decrypt_buf) + self._msl_decrypt_buf.clear() + self._stats["msl_decrypted"] += 1 + + # 最後に保存した MSL リクエストと同じディレクトリに保存 + if self._msl_last_save: + save_dir = self._msl_last_save["dir"] + base = self._msl_last_save["base"] + self._save_msl_plaintext(save_dir, base, "resp", combined) + url = self._msl_last_save.get("url", "?") + print(f" \U0001f513 MSL resp decrypted {url} ({fmt_size(len(combined))})") + + def _save_msl_plaintext(self, save_dir: Path, base: str, + direction: str, data: bytes): + """MSL 平文をファイルに保存. JSON なら整形して .json, それ以外は .bin.""" + try: + text = data.decode("utf-8") + parsed = json.loads(text) + content = json.dumps(parsed, indent=2, ensure_ascii=False) + path = save_dir / f"{base}_{direction}_plain.json" + path.write_text(content + "\n") + except (UnicodeDecodeError, json.JSONDecodeError): + path = save_dir / f"{base}_{direction}_plain.bin" + path.write_bytes(data) + + # ─── ファイル保存 ─── + + def _save_request(self, seq: int, req: dict, status, status_text: str, + protocol: str, resp_headers: dict, + body: bytes | None, body_size: int, error: str | None): + domain = req.get("domain", "unknown") + url = req.get("url", "unknown") + method = req.get("method", "GET") + req_headers = req.get("headers") or {} + + # URL パスに基づくネストされたディレクトリ構造 + parsed = urlparse(url) + path_segments = [s for s in parsed.path.strip("/").split("/") if s] + + save_dir = self.session_dir / sanitize(domain) + for seg in path_segments: + save_dir = save_dir / sanitize(seg) + save_dir.mkdir(parents=True, exist_ok=True) + + # パス単位で連番管理 + path_key = f"{domain}/{'/'.join(path_segments) or '/'}" + self._domain_seq[path_key] = self._domain_seq.get(path_key, 0) + 1 + dseq = self._domain_seq[path_key] + base_name = f"{dseq:04d}" + + lines: list[str] = [] + + # ── Request セクション ── + lines.append(f"## Request") + lines.append("") + lines.append(f"```") + lines.append(f"{method} {parsed.path}{'?' + parsed.query if parsed.query else ''} {protocol}") + lines.append(f"Host: {parsed.netloc}") + for k, v in req_headers.items(): + lines.append(f"{k}: {v}") + lines.append(f"```") + + # リクエストボディ + req_body_bytes: bytes | None = None + req_body_b64 = req.get("req_body_b64") + if req_body_b64: + try: + req_body_bytes = base64.b64decode(req_body_b64) + except Exception: + pass + + if req_body_bytes: + ct = req_headers.get("Content-Type", + req_headers.get("content-type", "")) + lines.append("") + lines.append(f"### Body ({fmt_size(len(req_body_bytes))})") + lines.append("") + if is_text_content(ct): + try: + text = req_body_bytes.decode("utf-8") + pretty = pretty_json(text) + lang = "json" if pretty else "" + lines.append(f"```{lang}") + lines.append(pretty or text) + lines.append("```") + except UnicodeDecodeError: + lines.append(f"*Binary data — {fmt_size(len(req_body_bytes))}*") + else: + lines.append(f"*Binary data — {fmt_size(len(req_body_bytes))}*") + elif req.get("hasBody"): + lines.append("") + lines.append(f"### Body") + lines.append("") + lines.append(f"*Body not captured*") + + # MSL リクエスト平文 + req_enc = req_headers.get("Content-Encoding", + req_headers.get("content-encoding", "")) + if "msl" in req_enc.lower(): + url_key = self._url_pattern(url) + queue = self._msl_req_queue.get(url_key, []) + msl_body = queue.pop(0) if queue else None + if msl_body: + self._save_msl_plaintext(save_dir, base_name, "req", msl_body) + # インラインにも追加 + lines.append("") + lines.append(f"### MSL Plaintext (decrypted request)") + lines.append("") + try: + text = msl_body.decode("utf-8") + pretty = pretty_json(text) + lines.append(f"```json") + lines.append(pretty or text) + lines.append("```") + except UnicodeDecodeError: + lines.append(f"*Binary — {fmt_size(len(msl_body))}*") + # レスポンス decrypt の紐付け用に保存先を記録 + self._msl_last_save = { + "dir": save_dir, "base": base_name, "url": url + } + + # ── Response セクション ── + lines.append("") + lines.append("---") + lines.append("") + + if error: + lines.append(f"## Response — ERROR") + lines.append("") + lines.append(f"```") + lines.append(error) + lines.append(f"```") + elif status is not None: + lines.append(f"## Response") + lines.append("") + lines.append(f"```") + lines.append(f"{protocol} {status} {status_text}") + for k, v in resp_headers.items(): + lines.append(f"{k}: {v}") + lines.append(f"```") + + if body: + ct = resp_headers.get("content-type", "") + lines.append("") + lines.append(f"### Body ({fmt_size(body_size)})") + lines.append("") + + if is_text_content(ct): + try: + text = body.decode("utf-8") + pretty = pretty_json(text) + lang = "json" if pretty else "" + lines.append(f"```{lang}") + lines.append(pretty or text) + lines.append("```") + except UnicodeDecodeError: + lines.append(f"*Binary data — {fmt_size(body_size)}*") + else: + lines.append(f"*Binary data — {fmt_size(body_size)}*") + + # ── メタデータ ── + lines.append("") + lines.append("---") + lines.append("") + lines.append(f"") + + # .md ファイル書き出し + md_path = save_dir / f"{base_name}.md" + md_path.write_text("\n".join(lines) + "\n") + + # ─── ユーティリティ ─── + + def _url_pattern(self, url: str) -> str: + """URL をパターン化 (重複抑制用).""" + try: + p = urlparse(url) + return f"{p.netloc}{p.path}" + except Exception: + return url + + def _append_jsonl(self, payload: dict): + """capture.jsonl にイベントを追記.""" + with open(self.capture_file, "a") as f: + f.write(json.dumps(payload, ensure_ascii=False) + "\n") + + def _print_summary(self): + s = self._stats + print(f"\n{'=' * 60}") + print(f" Capture Summary") + print(f" Requests: {s['requests']}") + print(f" Responses: {s['responses']}") + print(f" MSL Decrypted: {s['msl_decrypted']}") + print(f" Errors: {s['errors']}") + print(f" Data: {fmt_size(s['bytes'])}") + print(f" Saved to: {self.session_dir}") + print() + + for domain_dir in sorted(self.session_dir.iterdir()): + if domain_dir.is_dir(): + md_files = list(domain_dir.rglob("*.md")) + plain_files = list(domain_dir.rglob("*_plain.*")) + if md_files: + extra = f", {len(plain_files)} decrypted" if plain_files else "" + print(f" {domain_dir.name}/ ({len(md_files)} files{extra})") + for f in sorted(md_files)[:8]: + rel = f.relative_to(domain_dir) + print(f" {rel}") + if len(md_files) > 8: + print(f" ... and {len(md_files) - 8} more") + + # エラー頻度の高い URL パターン + if self._error_counts: + print() + print(f" Error patterns:") + for pat, cnt in sorted(self._error_counts.items(), + key=lambda x: -x[1])[:5]: + print(f" {cnt:>4}x {pat}") + + print(f"{'=' * 60}") + + +# ─── Main ──────────────────────────────────────────────── + +def main(): + script = sys.argv[1] if len(sys.argv) > 1 else "hook_cronet.js" + host = os.getenv("ANDROID_HOST", "192.168.0.37") + bundle_id = "com.netflix.mediaclient" + + if not Path(script).exists(): + print(f"[!] Script not found: {script}") + sys.exit(1) + + print(f"[*] Platform: Android (Frida Python API)") + print(f"[*] Host: {host}") + print(f"[*] Target: {bundle_id}") + print(f"[*] Script: {script}") + + capture = CronetCapture(host, bundle_id, script) + + def shutdown(sig, frame): + capture.stop() + + signal.signal(signal.SIGINT, shutdown) + signal.signal(signal.SIGTERM, shutdown) + + try: + capture.start() + except KeyboardInterrupt: + pass + except frida.ServerNotRunningError: + print(f"[!] Frida server is not running on {host}") + print(f" Start frida-server on the device first.") + sys.exit(1) + except frida.ProcessNotFoundError: + print(f"[!] Could not spawn {bundle_id}") + sys.exit(1) + except Exception as e: + print(f"[!] {type(e).__name__}: {e}") + finally: + capture.stop() + + +if __name__ == "__main__": + main()