feat(docs,tools): add iOS MSL analysis docs and HKDF param search scripts

Introduces detailed documentation for the iOS Netflix MSL client, including work plans and a comprehensive decryption pipeline, to support reverse engineering and protocol understanding.

Adds Python scripts that automate brute-force searching of HKDF parameters, support key derivation analysis across multiple encryption and HMAC key candidates, and verify DH parameter consistency.

Facilitates future development of a standalone iOS MSL client and aids in identifying the correct key derivation logic needed for cross-platform manifest decryption.
This commit is contained in:
tkgstrator
2026-04-08 10:12:57 +00:00
parent b9fdfc5f4f
commit f3e91bf143
5 changed files with 1727 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
# 作業計画書: iOS MSL クライアントライブラリ
日時: 2026-04-08T19:10:00+09:00
## 目標
Netflix iOS MSL 通信を Python で独立実装し、動画マニフェスト取得まで実行する。
## 判明済みの事実
### 暗号パラメータ (全て確定済み)
| パラメータ | 値 | 備考 |
|-----------|-----|------|
| DH 素数 p | `9694e9d8...e27df` (1024-bit) | NFWebCrypto にハードコード、セッション間で固定 |
| DH 生成元 g | `5` | 固定 |
| AES モード | AES-128-CBC + PKCS7 | enc_key = 16 bytes |
| HMAC | HMAC-SHA256 | hmac_key = 32 bytes |
| IV 格納 | ciphertext の先頭 16 bytes に prepend | IV フィールド (key 7) は空 |
### CBOR メッセージ構造 (全て確定済み)
| メッセージ種別 | トップレベルキー | 特徴 |
|--------------|----------------|------|
| appboot リクエスト | {16, 33, 34} + {16, 64} | entity_auth_data (34) あり、header (32) なし |
| appboot レスポンス | {16, 32, 33} + {16, 64} | key_response_data (33) にセッション鍵 |
| 通常リクエスト (manifest 等) | {16, 32, 33} + {16, 64} | entity_auth なし、header (32) あり |
| 通常レスポンス | 同上 | gzip ラップされる場合あり |
### entity_auth_data 内部構造 (FAIRPLAY_MGK_APPID)
```
key 30: "FAIRPLAY_MGK_APPID"
key 35: {
50: bytes(8096) ← device_key_data (デバイス固有)
80: "NFAPPL-02-IPHONE9=1-" ← ESN プレフィックス
81: "NFAPPL-02-IPHONE9=1-AD04..." ← PRV ESN 全体
"apphmac": bytes(32) ← HMAC-SHA256
"appid": "a2becfec-b286-535c-b884-903a384caee6"
"appkeyversion": 1
"devicetoken": bytes(216)
}
```
### key_exchange_data 構造
```
appboot: key 6 = bytes(352), key 8 = "{ESN}" (suffix なし)
通常 MSL: key 6 = bytes(1216), key 8 = "{ESN}_5" (suffix あり)
共通: key 7 = bytes(0), key 9 = bytes(16) nonce
```
### HTTP ヘッダー (キャプチャ確認済み)
```
Host: appboot.netflix.com
Content-Type: application/x-www-form-urlencoded
User-Agent: Netflix/24 CFNetwork/1335.0.3.4 Darwin/21.6.0
X-Netflix.APIAction: appboot
X-Netflix.Request.Attempt: 1
Cookie: nfvdid=...
```
## 未解明の要素
| 項目 | 重要度 | 解明方法 |
|------|--------|----------|
| **HKDF パラメータ** (shared_secret → enc_key + hmac_key) | ★★★ 必須 | Tweak で HKDF 関数をフック、または総当たり (salt/info/length の組み合わせ) |
| **entity_auth_data の生成ロジック** | ★★☆ | キャプチャからバイナリテンプレートとして再利用可能。独自生成は TFIT 解明が必要で困難 |
| **key 33.6 の構造** (352B/1216B) | ★★☆ | DH pub_key (128B) + 追加データの関係を解明。キャプチャテンプレート再利用も可 |
| **HMAC 署名対象** | ★☆☆ | キャプチャの signature と hmac_key で検証対象データを特定 |
## タスク一覧
### Phase 0: 残る未解明要素の調査 (並列)
#### tweak-engineer 担当
- [ ] T-1. **HKDF パラメータのキャプチャ** — NFWebCrypto の HKDF シンボルを特定してフック。salt, info, output length を記録
- [ ] T-2. **ESN キャプチャ** — `IosMslClient.esn` getter をフックして `g_keys[@"esn"]` に保存
- [ ] T-3. **Cookie キャプチャ** — `NSHTTPCookieStorage.cookiesForURL:` をフックし NetflixId / SecureNetflixId を抽出
#### python-engineer 担当
- [ ] P-0. **HKDF 総当たり検証** — キャプチャ済みの shared_secret / enc_key / hmac_key を使い、一般的な HKDF パラメータ (SHA-256, 空 salt, 各種 info 文字列) で enc_key が再現できるか検証。Tweak の HKDF フックと並行して進める
- [ ] P-1. **HMAC 署名対象の特定** — キャプチャの signature (key 16) と hmac_key を照合し、署名入力データの範囲を確定
- [ ] P-2. **キャプチャテンプレート抽出** — appboot / manifest リクエストの entity_auth_data, key_exchange_data, header をバイナリテンプレートとして `raws/templates/` に保存するスクリプト作成
### Phase 1: エンコーダー・クライアント実装
#### python-engineer 担当
- [ ] P-3. **DH 鍵交換の Python 実装** — `crypto.py` に `generate_dh_keypair(p, g)`, `compute_dh_shared_secret(priv_key, server_pub, p)`, `derive_session_keys(shared_secret)` を追加
- [ ] P-4. **iOS バイナリフレームのエンコード** — `cbor_encoder.py` に `build_ios_payload_frame()` を追加。復号で判明した構造 (bstr(9) + 0x3E + gzip bstr + trailer) を再現
- [ ] P-5. **encrypt_payload の IV prepend 対応** — ciphertext の先頭に IV を prepend し、IV フィールドを空にする iOS 形式
- [ ] P-6. **`ios_client.py` 新規作成** — Encoder + Decoder + HTTP を統合した iOS MSL クライアント
- `load_session(keys_path)` — 鍵ロード (Tweak JSON 対応)
- `load_templates(template_dir)` — entity_auth / header テンプレートをロード
- `set_cookies(nfvdid, netflix_id, secure_netflix_id)` — Cookie 設定
- `appboot()` — DH 鍵交換 + セッション確立
- `get_manifest(video_id)` — マニフェスト取得
### Phase 2: マニフェスト取得
#### python-engineer 担当
- [ ] P-7. **manifest リクエスト構築** — 復号済みキャプチャから manifest ペイロード JSON の構造を再現
- [ ] P-8. **`tools/fetch_manifest.py` CLI 作成** — `--keys`, `--cookies`, `--video-id` で実行
- [ ] P-9. **差分検証** — 生成した CBOR と実キャプチャの構造比較ツール
## 実行順序
```
Phase 0 (並列):
├── [tweak] T-1 (HKDF), T-2 (ESN), T-3 (Cookie)
└── [python] P-0 (HKDF検証), P-1 (HMAC), P-2 (テンプレート)
↓
Phase 1 (順次):
P-3 (DH実装) → P-4 (フレームエンコード) → P-5 (IV prepend) → P-6 (クライアント)
↓
Phase 2 (順次):
P-7 (manifest構築) → P-8 (CLI) → P-9 (検証)
```
## 成果物
| ファイル | 説明 |
|---------|------|
| `src/netflix_msl/ios_client.py` | iOS MSL クライアント (メイン) |
| `src/netflix_msl/crypto.py` | DH 鍵交換 + HKDF 追加 (既存拡張) |
| `src/netflix_msl/cbor_encoder.py` | iOS フレームエンコード追加 (既存拡張) |
| `src/netflix_msl/constants.py` | iOS 固有定数追加 (既存拡張) |
| `tools/extract_msl_template.py` | テンプレート抽出 CLI |
| `tools/fetch_manifest.py` | マニフェスト取得 CLI |
| `raws/templates/` | 抽出済みテンプレート |
| `packages/tweak/AppbootKeyExtract/` | HKDF / ESN / Cookie キャプチャ拡張 |
## リスク・注意点
- **HKDF が最大のブロッカー**: shared_secret → enc_key + hmac_key の導出方法が判明しないと、Python 独立での鍵交換ができない。Tweak キャプチャ鍵で代替可能だが、毎回デバイスが必要になる
- **entity_auth_data はテンプレート再利用**: TFIT ホワイトボックス鍵導出の解明は現実的でないため、キャプチャした entity_auth_data をそのまま再利用する。デバイス/アカウント固有のため、別デバイスでは使えない
- **key 33.6 (352B/1216B)** の内部構造: DH pub_key (128B) + 認証データの関係が未解明。テンプレート内の pub_key 部分のみ差し替えが必要
- **Cookie の有効期限**: 定期的に再取得が必要
- **レート制限**: Netflix サーバーへのリクエスト頻度に注意
+530
View File
@@ -0,0 +1,530 @@
# iOS MSL 復号パイプライン
解析日: 2026-04-08
ツール: AppbootKeyExtract (Tweak) + mitmproxy + Python CborMslDecoder
---
## 1. 概要
Netflix iOS アプリ (Argo) の MSL (Message Security Layer) 通信を、Tweak による鍵キャプチャと
mitmproxy によるトラフィックキャプチャの組み合わせで end-to-end 復号するパイプライン。
```
iOS App (Argo)
│
├── [Tweak] AppbootKeyExtract ──→ msl_keys.json (enc_key, hmac_key, DH keys)
│
└── [mitmproxy] HTTPS traffic ──→ .bin files (CBOR MSL captures)
│
Python CborMslDecoder
│
復号済み JSON ペイロード
```
---
## 2. 鍵の取得方法
### 2.1 鍵交換プロトコル (Scheme 3: DH ベース)
iOS Netflix は Scheme 3 (DH ベース鍵交換) を使用する。
Chrome/Android の `ASYMMETRIC_WRAPPED (JWK_RSA)` とは異なる独自プロトコル。
```mermaid
sequenceDiagram
participant App as Netflix App
participant NFC as NFWebCrypto.framework
participant Srv as Netflix Server
Note over App,NFC: Phase 1: DH 鍵ペア生成
App->>NFC: DH_generate_key(dh)
NFC-->>App: pub_key (128 bytes), priv_key (128 bytes)
Note over App,Srv: Phase 2: appboot リクエスト
App->>Srv: POST /appboot<br/>CBOR {key 33.6: DH pub_key (暗号化済み)}
Srv-->>App: CBOR {key 33.6: サーバー DH 応答}
Note over App,NFC: Phase 3: 共有秘密の計算
App->>NFC: DH_compute_key(server_pub, dh)
NFC-->>App: shared_secret (128 bytes)
Note over NFC: Phase 4: HKDF でセッション鍵導出
NFC->>NFC: HKDF(shared_secret) → enc_key + hmac_key
Note over App: Phase 5: MSL 通信開始
App->>NFC: AES_set_encrypt_key(enc_key, 128)
App->>NFC: HMAC(hmac_key, data)
App->>Srv: 暗号化 MSL ペイロード
```
### 2.2 鍵の種類と用途
| 鍵 | サイズ | 用途 | 取得方法 |
|----|--------|------|----------|
| DH 公開鍵 (`dh_pub_key`) | 128 bytes (1024-bit) | 鍵交換リクエストに含まれる | `DH_generate_key` フック |
| DH 秘密鍵 (`dh_priv_key`) | 128 bytes (1024-bit) | 共有秘密計算に使用 (ローカルのみ) | `DH_generate_key` フック |
| DH 共有秘密 (`dh_shared_secret`) | 128 bytes (1024-bit) | HKDF の入力 | `DH_compute_key` フック |
| 暗号化鍵 (`session_enc_key`) | 16 bytes (AES-128) | MSL ペイロードの AES-CBC 暗号化/復号 | `AES_set_encrypt_key` フック |
| 署名鍵 (`session_hmac_key`) | 32 bytes (HMAC-SHA256) | MSL ペイロードの署名/検証 | `HMAC` フック |
### 2.3 フック対象: NFWebCrypto.framework
Netflix は iOS 標準の CommonCrypto / Security.framework を **使用しない**。
代わりに、自前でバンドルした OpenSSL 互換ライブラリ `NFWebCrypto.framework` (約 2.3 MB) を使用する。
```
NFWebCrypto.framework/NFWebCrypto
├── DH_generate_key ← DH 鍵ペア生成
├── DH_compute_key ← DH 共有秘密計算
├── DH_get0_key ← DH 構造体からの鍵取得 (補助)
├── BN_num_bits ← BIGNUM ビット数 (補助)
├── BN_bn2bin ← BIGNUM → バイナリ変換 (補助)
├── AES_set_encrypt_key ← AES 暗号化鍵設定
├── AES_set_decrypt_key ← AES 復号鍵設定
└── HMAC ← HMAC 計算
```
これらは NFWebCrypto のエクスポートシンボルとして公開されているため、
`dlsym` で解決して `MSHookFunction` でフック可能。
> **注意**: `MslClient.framework` 内部の C++ 関数 (`aesCbcEncryptDecrypt` 等) は
> LTO (Link-Time Optimization) によりシンボルが除去されており、直接フックできない。
> NFWebCrypto のエクスポート関数をフックすることで間接的に鍵を取得する。
### 2.4 鍵のライフサイクル
```
アプリ起動
│
├── [pre_appboot] Keychain キャッシュから前回セッション鍵を復元
│ └── AES_set_encrypt_key / HMAC が呼ばれる (キャッシュ鍵)
│
├── appboot リクエスト送信
│ └── DH_generate_key → pub_key, priv_key
│
├── appboot レスポンス受信
│ └── DH_compute_key → shared_secret
│ └── HKDF → 新しい enc_key, hmac_key
│
├── setDidAppboot: YES ← ★ appboot 完了マーカー
│
└── [post_appboot] 新しいセッション鍵で MSL 通信開始
├── AES_set_encrypt_key(enc_key, 128) ← session_enc_key
├── HMAC(hmac_key, payload) ← session_hmac_key
├── manifest, license, logblob, graphql 等
└── 以降の全 MSL リクエスト/レスポンスに使用
```
Tweak は `IosMslClient.setDidAppboot:` を ObjC メソッドフックで監視し、
pre/post appboot のフェーズを区別して鍵を記録する。
### 2.5 鍵の複数出現について
1セッション中に AES/HMAC フックは複数回発火する:
| フェーズ | AES-128 鍵 | HMAC-SHA256 鍵 | 説明 |
|---------|-----------|---------------|------|
| pre_appboot | 0-2 個 | 1-3 個 | Keychain キャッシュからの復元、TFIT 鍵導出 |
| post_appboot | 2-5 個 | 5-11 個 | HKDF 出力、暗号化/復号の各呼び出し |
**最終的な `session_enc_key` / `session_hmac_key` が復号に使用すべき鍵。**
これは `g_keys` 辞書の最新値として Tweak が記録する。
---
## 3. Tweak 実装: AppbootKeyExtract
### 3.1 フックアーキテクチャ
```
AppbootKeyExtract (dylib, Substrate/Orion)
│
├── MSHookMessageEx ─── IosMslClient.setDidAppboot: (ObjC)
│
└── MSHookFunction ──── NFWebCrypto exports (C)
├── DH_generate_key
├── DH_compute_key
├── AES_set_encrypt_key
├── AES_set_decrypt_key
└── HMAC
```
### 3.2 DH 鍵のキャプチャ
```c
// DH_generate_key フック
static int hook_DH_generate_key(DH *dh) {
int ret = orig_DH_generate_key(dh); // オリジナル関数を呼ぶ
if (ret == 1) {
const BIGNUM *pub = NULL, *priv = NULL;
fn_DH_get0_key(dh, &pub, &priv); // DH 構造体からキーを取得
// BIGNUM → バイナリ → hex 文字列に変換して保存
int pubBytes = (fn_BN_num_bits(pub) + 7) / 8;
uint8_t *buf = malloc(pubBytes);
fn_BN_bn2bin(pub, buf);
g_keys[@"dh_pub_key"] = hexEncode(buf, pubBytes);
// priv_key も同様に保存
}
return ret;
}
// DH_compute_key フック
static int hook_DH_compute_key(unsigned char *key, const BIGNUM *pub_key, DH *dh) {
int ret = orig_DH_compute_key(key, pub_key, dh);
if (ret > 0) {
g_keys[@"dh_shared_secret"] = hexEncode(key, ret);
}
return ret;
}
```
**ポイント**: `DH_get0_key` / `BN_num_bits` / `BN_bn2bin` も NFWebCrypto からの
`dlsym` で解決する必要がある (システム OpenSSL とは別のライブラリ)。
### 3.3 セッション鍵のキャプチャ
```c
// AES_set_encrypt_key フック — AES-128 鍵のみ記録
static int hook_AES_set_encrypt_key(const unsigned char *userKey, int bits, void *key) {
if (bits == 128) {
NSString *hex = hexEncode(userKey, 16);
g_keys[g_appbootDone ? @"session_enc_key" : @"pre_session_enc_key"] = hex;
}
return orig_AES_set_encrypt_key(userKey, bits, key);
}
// HMAC フック — 32 バイト鍵のみ記録 (HMAC-SHA256)
static unsigned char *hook_HMAC(const void *evp_md, const void *key, int key_len,
const unsigned char *d, size_t n,
unsigned char *md, unsigned int *md_len) {
if (key_len == 32) {
NSString *hex = hexEncode((const uint8_t *)key, key_len);
g_keys[g_appbootDone ? @"session_hmac_key" : @"pre_session_hmac_key"] = hex;
}
return orig_HMAC(evp_md, key, key_len, d, n, md, md_len);
}
```
**フィルタ条件**:
- AES: `bits == 128` のみ。AES-256 (鍵導出、TFIT) は除外
- HMAC: `key_len == 32` のみ。HMAC-SHA256 のセッション署名鍵に一致
### 3.4 出力ファイル
Tweak は `NSTemporaryDirectory()/msl_keys.json` に以下の構造で保存する:
```json
{
"dh_pub_key": "052cf384eed2c4...",
"dh_priv_key": "5a7dba45126dde...",
"dh_shared_secret": "159ee6f14fce...",
"session_enc_key": "893bbf2a18012bdd73628548c4b77827",
"session_hmac_key": "f5b90484802f0ff688978ea657cb621c446f06d8f0b5cf908620512a8aaf2a59",
"pre_session_enc_key": "...",
"pre_session_hmac_key": "...",
"aes_key_history": [
{"key": "...", "bits": 128, "phase": "post_appboot"},
...
],
"hmac_key_history": [
{"key": "...", "phase": "post_appboot"},
...
],
"timestamp": "2026-04-08T18:20:06"
}
```
デバイスからの取得:
```bash
ssh -p 2222 root@host.docker.internal \
'cat /private/var/mobile/Containers/Data/Application/*/tmp/msl_keys.json' \
> raws/msl_keys.json
```
---
## 4. iOS CBOR MSL フォーマット
### 4.1 メッセージ構造 (JSON MSL との差異)
iOS は JSON ではなく **CBOR** (Concise Binary Object Representation) で MSL メッセージをエンコードする。
1つの HTTP ボディに複数の CBOR オブジェクトが連結される。
```
[CBOR Item 0: ヘッダー] [CBOR Item 1: ペイロード]
├── key 32: header ├── key 64: payload_chunk (暗号化)
├── key 33: key_exchange └── key 16: signature
├── key 34: entity_auth
└── key 16: signature
```
### 4.2 CBOR 数値キーマッピング
| キー | 用途 | JSON MSL 対応 |
|------|------|--------------|
| 32 | header (capabilities) | `headerdata` |
| 33 | key_request/response_data | `keyrequestdata` |
| 34 | entity_auth_data | `entityauthdata` |
| 64 | payload_chunk (暗号化ペイロード) | `payload` |
| 16 | message signature | `signature` |
### 4.3 payload_chunk 内部構造 (CBOR key 64)
key 64 の値はさらに CBOR エンコードされた dict:
| サブキー | 用途 | 値の型 | サイズ |
|---------|------|--------|-------|
| 6 | ciphertext (IV prepend) | bytes | 可変 |
| 7 | IV フラグ | bytes | 0-1 byte |
| 8 | keyid | string | `{ESN}_{N}` or `N` |
| 9 | HMAC | bytes | 16 bytes |
### 4.4 IV の扱い (JSON MSL との最大の差異)
**JSON MSL** では IV は `"iv"` フィールドに Base64 エンコードされた 16 バイトとして格納される。
**iOS CBOR MSL** では IV は ciphertext の先頭 16 バイトに prepend される:
```
CBOR key 7 (IV field): 空 (0 bytes) または 0x00 (1 byte) ← ダミー
CBOR key 6 (ciphertext): [16-byte IV][actual ciphertext]
```
復号時は ciphertext の先頭 16 バイトを IV として分離する:
```python
iv = ciphertext[:16]
actual_ciphertext = ciphertext[16:]
plaintext = aes_cbc_decrypt(actual_ciphertext, key=enc_key, iv=iv)
```
### 4.5 復号後の平文構造
復号後の平文 (PKCS7 アンパディング済み) は、リクエストとレスポンスで異なる構造を持つ。
#### リクエスト: iOS バイナリフレーム
```
[0x49][9-byte header][0x3E][CBOR bstr(N): gzip圧縮データ][trailer]
```
| オフセット | サイズ | 内容 |
|-----------|--------|------|
| 0 | 1 | `0x49` = CBOR bstr(9) |
| 1 | 9 | フレームヘッダー (例: `50 1b 00 00 00 00 00 00 00`) |
| 10 | 1 | `0x3E` (62) = フレームタイプ/デリミタ |
| 11 | 3+ | CBOR bstr ヘッダー (例: `59 02 e7` = 743 bytes) |
| 14 | N | gzip 圧縮された JSON ペイロード |
| 14+N | 可変 | トレーラー (sequence number 等) |
gzip 展開すると JSON ペイロードが得られる:
```json
{
"esn": "NFAPPL-02-IPHONE9=1-PXA-...",
"method": "call",
"locale": "ja-JP",
"pathFormat": "graph",
"callPath": "[\"moneyball\",\"appleSignUp\",\"next\"]",
...
}
```
#### レスポンス: deflate 圧縮
```
[0x00 0x00][raw deflate 圧縮された JSON]
```
| オフセット | サイズ | 内容 |
|-----------|--------|------|
| 0 | 2 | `0x00 0x00` = ヘッダー |
| 2 | 1 | deflate データの先頭バイト (例: `0xff`) |
| 3 | N | raw deflate 圧縮データ |
`zlib.decompress(plaintext[3:], wbits=-15)` で展開すると JSON ペイロードが得られる:
```json
{
"value": {"moneyball": {"appleSignUp": {"next": {"mode": "welcomeConsumptionOnly", ...}}}},
"paths": [...]
}
```
#### レスポンス: 非圧縮
一部のレスポンス (logblob 等) は圧縮なしの CBOR integer (`-3` 等のステータスコード) で返される。
### 4.6 HTTP レスポンスの gzip ラッピング
mitmproxy がキャプチャしたレスポンスファイルは、HTTP Content-Encoding: gzip がそのまま保存される。
そのため `.bin` ファイルの先頭が `1f 8b` (gzip magic) で始まる場合は、
CBOR デコードの前に gzip 展開が必要:
```python
if raw_data[:2] == b"\x1f\x8b":
raw_data = gzip.decompress(raw_data)
```
---
## 5. Python 復号パイプライン
### 5.1 コンポーネント
| ファイル | 役割 |
|---------|------|
| `src/netflix_msl/crypto.py` | AES-CBC 暗号化/復号、HMAC-SHA256、鍵インポート |
| `src/netflix_msl/cbor_decoder.py` | CBOR MSL メッセージ解析、IV 分離、平文展開 |
| `tools/decrypt_capture.py` | オフライン復号 CLI |
### 5.2 使い方
#### 鍵ファイルからの復号 (CLI)
```bash
# 単一ファイル
python tools/decrypt_capture.py \
--keys raws/msl_keys.json \
--input raws/ios/20260408/raw/req_660_iosui_2026-04-08T09-20-06-370Z.bin
# ディレクトリ一括
python tools/decrypt_capture.py \
--keys raws/msl_keys.json \
--input-dir raws/ios/20260408/raw/
# hex で鍵を直接指定
python tools/decrypt_capture.py \
--enc-key 893bbf2a18012bdd73628548c4b77827 \
--sign-key f5b90484802f0ff688978ea657cb621c446f06d8f0b5cf908620512a8aaf2a59 \
--input capture.bin
```
#### Python API
```python
from netflix_msl.crypto import NetflixCrypto
from netflix_msl.cbor_decoder import CborMslDecoder
# 鍵をロード
crypto = NetflixCrypto()
crypto.import_keys_from_file("raws/msl_keys.json")
# デコーダーを初期化
decoder = CborMslDecoder(crypto)
# MSL メッセージを復号
with open("capture.bin", "rb") as f:
result = decoder.process_message(f.read())
# 結果
for payload in result["payloads"]:
if "body" in payload:
print(payload["body"]) # 復号済み JSON
```
### 5.3 鍵ファイル互換性
`crypto.import_keys_from_file()` は以下の2形式に対応:
| 形式 | フィールド名 | 生成元 |
|------|------------|--------|
| Frida 形式 | `enc_key`, `sign_key` | Frida フックスクリプト |
| Tweak 形式 | `session_enc_key`, `session_hmac_key` | AppbootKeyExtract |
---
## 6. 運用手順
### 6.1 準備
```bash
# 1. Tweak をビルド・インストール
make -C packages/tweak/AppbootKeyExtract package install THEOS_DEVICE_IP=192.168.0.49
# 2. mitmproxy を起動 (別ターミナル)
# (既に起動済みならスキップ)
# 3. Netflix アプリを起動
ssh -p 2222 root@host.docker.internal \
'uiopen --bundleid com.netflix.Netflix'
# 4. 5秒待ってから生存確認
ssh -p 2222 root@host.docker.internal \
'ps aux | grep Argo | grep -v grep'
```
### 6.2 鍵の取得
```bash
# デバイスから鍵ファイルをダウンロード
ssh -p 2222 root@host.docker.internal \
'cat /private/var/mobile/Containers/Data/Application/*/tmp/msl_keys.json' \
> raws/msl_keys.json
# 鍵の確認
python3 -c "
import json
with open('raws/msl_keys.json') as f:
keys = json.load(f)
print(f'enc_key: {keys.get(\"session_enc_key\", \"N/A\")}')
print(f'hmac_key: {keys.get(\"session_hmac_key\", \"N/A\")}')
print(f'timestamp: {keys.get(\"timestamp\", \"N/A\")}')
"
```
### 6.3 キャプチャの復号
```bash
# 鍵のタイムスタンプとキャプチャファイルの時刻が一致していることを確認
# (異なるセッションの鍵では復号できない)
python tools/decrypt_capture.py \
--keys raws/msl_keys.json \
--input-dir raws/ios/20260408/raw/ \
2>errors.log
```
### 6.4 セッション鍵の更新
**重要**: セッション鍵はアプリ起動ごとに変わる。
新しいキャプチャを復号するには、同一セッションの鍵が必要。
鍵のキャッシュによりアプリが前回の鍵を再利用する場合がある。
新しい鍵交換を強制するには:
```bash
ssh -p 2222 root@host.docker.internal \
'rm -rf /private/var/mobile/Containers/Data/Application/*/Library/Caches/*'
```
---
## 7. 既知の制限
| 制限 | 詳細 |
|------|------|
| セッション一致が必要 | 鍵とキャプチャは同一セッションのものでなければ復号できない |
| HMAC 検証未実装 | 署名対象データの特定が未完了のため、signature_valid は常に false |
| 一部レスポンスが非 JSON | logblob レスポンス等は CBOR integer (ステータスコード) のみ |
| pre_appboot トラフィック | キャッシュ鍵で暗号化されたトラフィックは `pre_session_*` 鍵で復号可能だが未検証 |
| appboot 自体の復号 | appboot リクエストは entity_auth_data 内の暗号化構造が未解明 |
---
## 8. プラットフォーム別 MSL フォーマット比較
| 項目 | Chrome/Android (JSON) | iOS (CBOR) |
|------|-----------------------|-----------|
| エンコーディング | JSON | CBOR (数値キー) |
| IV 格納 | `"iv"` フィールド (Base64) | ciphertext に prepend |
| ciphertext 格納 | `"ciphertext"` (Base64) | 生バイナリ |
| HMAC サイズ | 32 bytes (SHA-256) | 16 bytes (truncated?) |
| 復号後圧縮 (リクエスト) | gzip (Base64 経由) | gzip (CBOR bstr 直接) |
| 復号後圧縮 (レスポンス) | gzip (Base64 経由) | raw deflate (`00 00` prefix) |
| 鍵交換スキーム | ASYMMETRIC_WRAPPED (JWK_RSA) | Scheme 3 (DH ベース) |
| メッセージ構造 | JSON 連結 | CBOR 連結 |
| HTTP レスポンス圧縮 | なし | gzip (Content-Encoding) |
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""
Netflix iOS MSL HKDF パラメータ探索スクリプト
shared_secret から enc_key / hmac_key を導出するパラメータを特定する
"""
import hashlib
import hmac
import itertools
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF, HKDFExpand
# キャプチャ済みデータ
DH_SHARED_SECRET = bytes.fromhex(
"76d784d86009dcc43eb5fdbe6bd53fdc6c4e3774a1c73e12468190735b11df7"
"95f18c4917b79dad9ba5a14a4c9951437a4a85a3d2ab1099072f517389fd8cb"
"7313092639a5d36bff8f819d034bceb9ed2f1369f2cb67a4667623b96832183"
"3717a9563c768fd8b4313b08ee9bae7d661146e09d03b6d7989f9705d51ae92"
"7275"
)
TARGET_ENC_KEY = bytes.fromhex("f4b5e0519e8022b2801768cdc88816d6")
TARGET_HMAC_KEY = bytes.fromhex(
"b733c6b8bd3c2d02098c6c679daa9b138f9e0d9d76f95d2c85240937d53c66c9"
)
TARGET_BOTH_ENC_FIRST = TARGET_ENC_KEY + TARGET_HMAC_KEY # 48 bytes
TARGET_BOTH_HMAC_FIRST = TARGET_HMAC_KEY + TARGET_ENC_KEY # 48 bytes
print(f"shared_secret ({len(DH_SHARED_SECRET)} bytes): {DH_SHARED_SECRET.hex()}")
print(f"target enc_key ({len(TARGET_ENC_KEY)} bytes): {TARGET_ENC_KEY.hex()}")
print(f"target hmac_key ({len(TARGET_HMAC_KEY)} bytes): {TARGET_HMAC_KEY.hex()}")
print()
found: list[str] = []
def check(label: str, derived: bytes) -> bool:
if derived[:16] == TARGET_ENC_KEY and derived[16:48] == TARGET_HMAC_KEY:
print(f"[ENC+HMAC MATCH] {label}")
found.append(label)
return True
if derived[:32] == TARGET_HMAC_KEY and derived[32:48] == TARGET_ENC_KEY:
print(f"[HMAC+ENC MATCH] {label}")
found.append(label)
return True
if derived[:16] == TARGET_ENC_KEY:
print(f"[ENC ONLY MATCH] {label} derived={derived.hex()}")
found.append(f"ENC_ONLY: {label}")
if derived[16:48] == TARGET_HMAC_KEY:
print(f"[HMAC ONLY (offset 16)] {label} derived={derived.hex()}")
found.append(f"HMAC_ONLY_16: {label}")
if derived[:32] == TARGET_HMAC_KEY:
print(f"[HMAC ONLY (offset 0)] {label}")
found.append(f"HMAC_ONLY_0: {label}")
return False
# ハッシュアルゴリズム候補
HASH_ALGS = {
"SHA-1": hashes.SHA1(),
"SHA-256": hashes.SHA256(),
"SHA-384": hashes.SHA384(),
"SHA-512": hashes.SHA512(),
}
# info 候補
INFO_CANDIDATES: list[tuple[str, bytes]] = [
("b''", b""),
("b'AES'", b"AES"),
("b'HMAC'", b"HMAC"),
("b'Netflix'", b"Netflix"),
("b'MSL'", b"MSL"),
("b'enc'", b"enc"),
("b'sign'", b"sign"),
("b'encryption'", b"encryption"),
("b'hmac'", b"hmac"),
("b'session'", b"session"),
("b'\\x00'", b"\x00"),
("b'\\x01'", b"\x01"),
("b'\\x02'", b"\x02"),
("b'\\x00\\x01'", b"\x00\x01"),
("b'KEY'", b"KEY"),
("b'WRAP'", b"WRAP"),
("b'SIGN'", b"SIGN"),
("b'ENC'", b"ENC"),
("b'wrapping'", b"wrapping"),
("b'signing'", b"signing"),
("b'Netflix MSL'", b"Netflix MSL"),
("b'Netflix MSL enc'", b"Netflix MSL enc"),
("b'Netflix MSL hmac'", b"Netflix MSL hmac"),
]
# salt 候補
SALT_CANDIDATES: list[tuple[str, bytes | None]] = [
("salt=None", None),
("salt=b''", b""),
("salt=b'\\x00'*32", b"\x00" * 32),
("salt=b'\\x00'*16", b"\x00" * 16),
("salt=b'\\x00'*20", b"\x00" * 20),
]
# 出力長候補
OUTPUT_LENGTHS = [48, 16, 32, 64]
print("=" * 70)
print("1. HKDF (extract + expand) の全組み合わせ試行")
print("=" * 70)
for hash_name, hash_alg in HASH_ALGS.items():
for salt_name, salt_val in SALT_CANDIDATES:
for info_name, info_val in INFO_CANDIDATES:
for out_len in OUTPUT_LENGTHS:
try:
# cryptography の HKDF は salt=None と salt=b"" の挙動が異なる
hkdf = HKDF(
algorithm=hash_alg,
length=out_len,
salt=salt_val,
info=info_val,
)
derived = hkdf.derive(DH_SHARED_SECRET)
label = f"HKDF hash={hash_name} {salt_name} info={info_name} len={out_len}"
check(label, derived)
except Exception:
pass
print()
print("=" * 70)
print("2. HKDFExpand のみ (PRK として shared_secret をそのまま使用)")
print("=" * 70)
for hash_name, hash_alg in HASH_ALGS.items():
for info_name, info_val in INFO_CANDIDATES:
for out_len in OUTPUT_LENGTHS:
try:
hkdf_expand = HKDFExpand(
algorithm=hash_alg,
length=out_len,
info=info_val,
)
derived = hkdf_expand.derive(DH_SHARED_SECRET)
label = f"HKDFExpand hash={hash_name} info={info_name} len={out_len}"
check(label, derived)
except Exception:
pass
print()
print("=" * 70)
print("3. 単純ハッシュ截断")
print("=" * 70)
# SHA-256(shared_secret)
h256 = hashlib.sha256(DH_SHARED_SECRET).digest()
print(f"SHA-256(shared_secret) = {h256.hex()}")
check("SHA-256(shared_secret)", h256 + b"\x00" * 16)
# SHA-384(shared_secret)
h384 = hashlib.sha384(DH_SHARED_SECRET).digest()
print(f"SHA-384(shared_secret) = {h384.hex()}")
check("SHA-384(shared_secret) enc[:16]+hmac[16:48]", h384)
# SHA-512(shared_secret)
h512 = hashlib.sha512(DH_SHARED_SECRET).digest()
print(f"SHA-512(shared_secret) = {h512.hex()}")
check("SHA-512(shared_secret) enc[:16]+hmac[16:48]", h512)
# SHA-1(shared_secret)
h1 = hashlib.sha1(DH_SHARED_SECRET).digest()
print(f"SHA-1(shared_secret) = {h1.hex()}")
print()
print("=" * 70)
print("4. shared_secret[:16] が enc_key か確認")
print("=" * 70)
print(f"shared_secret[:16] = {DH_SHARED_SECRET[:16].hex()}")
print(f"target enc_key = {TARGET_ENC_KEY.hex()}")
if DH_SHARED_SECRET[:16] == TARGET_ENC_KEY:
print("[MATCH] shared_secret[:16] == enc_key")
else:
print("[NO MATCH]")
print()
print("=" * 70)
print("5. HMAC-SHA256 ベースの KDF")
print("=" * 70)
# HMAC-SHA256(key=shared_secret, msg=various_inputs)
hmac_inputs: list[tuple[str, bytes]] = [
("msg=b'\\x00'", b"\x00"),
("msg=b'\\x01'", b"\x01"),
("msg=b''", b""),
("msg=b'enc'", b"enc"),
("msg=b'hmac'", b"hmac"),
("msg=b'\\x00'*16", b"\x00" * 16),
("msg=b'\\x00'*32", b"\x00" * 32),
]
for msg_name, msg_val in hmac_inputs:
h = hmac.new(DH_SHARED_SECRET, msg_val, hashlib.sha256).digest()
label = f"HMAC-SHA256(key=shared_secret, {msg_name})"
check(label + " [full]", h + b"\x00" * 16)
# HMAC-SHA256(key=SHA256(shared_secret), msg=various)
prk = hashlib.sha256(DH_SHARED_SECRET).digest()
for msg_name, msg_val in hmac_inputs:
h = hmac.new(prk, msg_val, hashlib.sha256).digest()
label = f"HMAC-SHA256(key=SHA256(shared_secret), {msg_name})"
check(label + " [full]", h + b"\x00" * 16)
print()
print("=" * 70)
print("6. 2ステップ HKDF (enc と hmac を別々に導出)")
print("=" * 70)
for hash_name, hash_alg in HASH_ALGS.items():
for salt_name, salt_val in SALT_CANDIDATES:
for info_enc_name, info_enc_val in INFO_CANDIDATES:
for info_hmac_name, info_hmac_val in INFO_CANDIDATES:
if info_enc_name == info_hmac_name:
continue
try:
hkdf_enc = HKDF(
algorithm=hash_alg,
length=16,
salt=salt_val,
info=info_enc_val,
)
enc = hkdf_enc.derive(DH_SHARED_SECRET)
if enc != TARGET_ENC_KEY:
continue
# enc が一致したら hmac も試す
hkdf_hmac = HKDF(
algorithm=hash_alg,
length=32,
salt=salt_val,
info=info_hmac_val,
)
hmac_key = hkdf_hmac.derive(DH_SHARED_SECRET)
label = (
f"2-step HKDF hash={hash_name} {salt_name} "
f"info_enc={info_enc_name} info_hmac={info_hmac_name}"
)
if enc == TARGET_ENC_KEY and hmac_key == TARGET_HMAC_KEY:
print(f"[FULL MATCH] {label}")
found.append(label)
elif enc == TARGET_ENC_KEY:
print(f"[ENC ONLY] {label} hmac={hmac_key.hex()}")
found.append(f"ENC_ONLY_2STEP: {label}")
except Exception:
pass
print()
print("=" * 70)
print("7. DH 秘密鍵から HKDF (dh_priv_key をソルトとして使用)")
print("=" * 70)
DH_PRIV_KEY = bytes.fromhex(
"46fe2839cd0e88e509d75e2b818cfe0f836e9c409ff684bfa4d3f79f1ddd931"
"690dedb9e379ce82f68db8d5b2acb10ae2c17f136010e3dca2698a593bbb91d"
"10a833df9f5d88d07905f8b5e55b9db592fc1811c9f5da0d9eeb11d0b3c7966"
"d1d6b1e2226f5b3c9359d05f48d97ee6c40623adc68d507fa871ab416786f6a"
"c038"
)
extra_salts: list[tuple[str, bytes]] = [
("salt=dh_priv_key", DH_PRIV_KEY),
("salt=SHA256(dh_priv_key)", hashlib.sha256(DH_PRIV_KEY).digest()),
]
for salt_name, salt_val in extra_salts:
for hash_name, hash_alg in HASH_ALGS.items():
for info_name, info_val in INFO_CANDIDATES:
for out_len in OUTPUT_LENGTHS:
try:
hkdf = HKDF(
algorithm=hash_alg,
length=out_len,
salt=salt_val,
info=info_val,
)
derived = hkdf.derive(DH_SHARED_SECRET)
label = f"HKDF hash={hash_name} {salt_name} info={info_name} len={out_len}"
check(label, derived)
except Exception:
pass
print()
print("=" * 70)
print("8. aes_key_history の他エントリとの関係確認")
print("=" * 70)
# 他の aes_key を確認
other_enc_keys = [
"0817065e29e6d1c8668473af9e13b3c2",
"97b99f4e88e8e73779aa20ac11877c5d",
]
print("shared_secret[:16]:", DH_SHARED_SECRET[:16].hex())
for k in other_enc_keys:
print(f"other enc_key: {k}")
print()
print("=" * 70)
print("結果サマリー")
print("=" * 70)
if found:
print(f"一致したパラメータ ({len(found)}件):")
for f in found:
print(f" - {f}")
else:
print("一致するパラメータは見つかりませんでした")
+380
View File
@@ -0,0 +1,380 @@
#!/usr/bin/env python3
"""
Netflix iOS MSL HKDF パラメータ探索 Part 2
- aes_key_history の全エントリを対象に試行
- pre_appboot の hmac_key も対象に追加
- 追加の info / salt パターンを試す
"""
import hashlib
import hmac as hmac_mod
import struct
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF, HKDFExpand
DH_SHARED_SECRET = bytes.fromhex(
"76d784d86009dcc43eb5fdbe6bd53fdc6c4e3774a1c73e12468190735b11df7"
"95f18c4917b79dad9ba5a14a4c9951437a4a85a3d2ab1099072f517389fd8cb"
"7313092639a5d36bff8f819d034bceb9ed2f1369f2cb67a4667623b96832183"
"3717a9563c768fd8b4313b08ee9bae7d661146e09d03b6d7989f9705d51ae92"
"7275"
)
# 全 aes_key_history エントリ (enc_key 候補)
ALL_ENC_KEYS = [
bytes.fromhex("0817065e29e6d1c8668473af9e13b3c2"),
bytes.fromhex("97b99f4e88e8e73779aa20ac11877c5d"),
bytes.fromhex("f4b5e0519e8022b2801768cdc88816d6"),
]
# 全 hmac_key_history エントリ (hmac_key 候補)
ALL_HMAC_KEYS = [
bytes.fromhex("19def2f90d06bc8dfd04a19dbd4588d4e7b8aa6ccacb200f9ae6acc49355917d"),
bytes.fromhex("e60e376f37d7d962512aea2f29a353c28b0fb95b1e77c43baf7459b21d1df649"),
bytes.fromhex("58c4e3d1cc2ce7bd73e846a1c3b00a9986aa039302d7bbf1a5508d5f9a49120f"),
bytes.fromhex("91f752f76d7ab4c2dc6e5b3ec1c0e5a16864421fe449be5457459602e298ebc1"),
bytes.fromhex("38b2030dd55e3367290213ca0d16ee079524ccd24fb7221a52145fb6de016fd8"),
bytes.fromhex("05ffd2d7407a6da255dfd89cde00504d1803ed81a8e5c17ea196c4498d01d825"),
bytes.fromhex("d45443fa11efec622c83b27c55f7a73143bdfa0d51820ac597b9e3fb5c28dbb0"),
bytes.fromhex("b733c6b8bd3c2d02098c6c679daa9b138f9e0d9d76f95d2c85240937d53c66c9"),
]
found: list[str] = []
def check_any(label: str, derived: bytes) -> bool:
"""全 enc_key x hmac_key の組み合わせをチェック"""
matched = False
for enc in ALL_ENC_KEYS:
for hmac_k in ALL_HMAC_KEYS:
combined_eh = enc + hmac_k
combined_he = hmac_k + enc
if len(derived) >= len(combined_eh) and derived[: len(combined_eh)] == combined_eh:
msg = f"[MATCH enc+hmac] {label} enc={enc.hex()} hmac={hmac_k.hex()}"
print(msg)
found.append(msg)
matched = True
if len(derived) >= len(combined_he) and derived[: len(combined_he)] == combined_he:
msg = f"[MATCH hmac+enc] {label} hmac={hmac_k.hex()} enc={enc.hex()}"
print(msg)
found.append(msg)
matched = True
if len(derived) >= 16 and derived[:16] == enc:
# enc だけ一致
pass # ノイズが多すぎるのでここでは出力しない
return matched
def check_enc_only(label: str, derived: bytes) -> bool:
for enc in ALL_ENC_KEYS:
if len(derived) >= 16 and derived[:16] == enc:
msg = f"[ENC MATCH] {label} enc={enc.hex()} full={derived.hex()}"
print(msg)
found.append(msg)
return True
return False
# ハッシュアルゴリズム
HASH_ALGS = {
"SHA-1": hashes.SHA1(),
"SHA-256": hashes.SHA256(),
"SHA-384": hashes.SHA384(),
"SHA-512": hashes.SHA512(),
}
# info 候補 (拡張版)
INFO_CANDIDATES: list[tuple[str, bytes]] = [
("b''", b""),
("b'AES'", b"AES"),
("b'HMAC'", b"HMAC"),
("b'Netflix'", b"Netflix"),
("b'MSL'", b"MSL"),
("b'enc'", b"enc"),
("b'sign'", b"sign"),
("b'encryption'", b"encryption"),
("b'hmac'", b"hmac"),
("b'session'", b"session"),
("b'\\x00'", b"\x00"),
("b'\\x01'", b"\x01"),
("b'\\x02'", b"\x02"),
("b'\\x03'", b"\x03"),
("b'\\x00\\x01'", b"\x00\x01"),
("b'\\x00\\x00\\x00\\x00'", b"\x00\x00\x00\x00"),
("b'KEY'", b"KEY"),
("b'WRAP'", b"WRAP"),
("b'SIGN'", b"SIGN"),
("b'ENC'", b"ENC"),
("b'wrapping'", b"wrapping"),
("b'signing'", b"signing"),
("b'Netflix MSL'", b"Netflix MSL"),
("b'Netflix MSL enc'", b"Netflix MSL enc"),
("b'Netflix MSL hmac'", b"Netflix MSL hmac"),
("b'keydata'", b"keydata"),
("b'wrapkey'", b"wrapkey"),
("b'enckey'", b"enckey"),
("b'hmackey'", b"hmackey"),
("b'masterkey'", b"masterkey"),
("b'sessionkey'", b"sessionkey"),
("b'NETFLIX'", b"NETFLIX"),
("b'netflix'", b"netflix"),
("int32(0)", struct.pack(">I", 0)),
("int32(1)", struct.pack(">I", 1)),
("int32(2)", struct.pack(">I", 2)),
("b'master_secret'", b"master_secret"),
("b'key_expansion'", b"key_expansion"),
("b'key expansion'", b"key expansion"),
]
# salt 候補 (拡張版)
SALT_CANDIDATES: list[tuple[str, bytes | None]] = [
("salt=None", None),
("salt=b''", b""),
("salt=b'\\x00'*32", b"\x00" * 32),
("salt=b'\\x00'*16", b"\x00" * 16),
("salt=b'\\x00'*20", b"\x00" * 20),
("salt=b'\\x00'*64", b"\x00" * 64),
("salt=b'\\x00'*128", b"\x00" * 128),
]
OUTPUT_LENGTHS = [16, 32, 48, 64, 80, 128]
print("=" * 70)
print("1. 全 enc_key/hmac_key 候補に対する HKDF 総当たり")
print("=" * 70)
for hash_name, hash_alg in HASH_ALGS.items():
for salt_name, salt_val in SALT_CANDIDATES:
for info_name, info_val in INFO_CANDIDATES:
for out_len in OUTPUT_LENGTHS:
try:
hkdf = HKDF(
algorithm=hash_alg,
length=out_len,
salt=salt_val,
info=info_val,
)
derived = hkdf.derive(DH_SHARED_SECRET)
label = f"HKDF hash={hash_name} {salt_name} info={info_name} len={out_len}"
check_any(label, derived)
except Exception:
pass
print()
print("=" * 70)
print("2. HKDFExpand のみ")
print("=" * 70)
for hash_name, hash_alg in HASH_ALGS.items():
for info_name, info_val in INFO_CANDIDATES:
for out_len in OUTPUT_LENGTHS:
try:
hkdf_expand = HKDFExpand(
algorithm=hash_alg,
length=out_len,
info=info_val,
)
derived = hkdf_expand.derive(DH_SHARED_SECRET)
label = f"HKDFExpand hash={hash_name} info={info_name} len={out_len}"
check_any(label, derived)
except Exception:
pass
print()
print("=" * 70)
print("3. NIST SP800-108 Counter Mode KDF (HMAC-SHA256 ベース)")
print("=" * 70)
def nist_counter_kdf(
prf_key: bytes,
label: bytes,
context: bytes,
length: int,
hash_alg: str = "sha256",
) -> bytes:
"""NIST SP800-108 Counter Mode"""
result = b""
counter = 1
while len(result) < length:
msg = struct.pack(">I", counter) + label + b"\x00" + context + struct.pack(">I", length * 8)
result += hmac_mod.new(prf_key, msg, hash_alg).digest()
counter += 1
return result[:length]
nist_labels: list[tuple[str, bytes]] = [
("label=b''", b""),
("label=b'enc'", b"enc"),
("label=b'hmac'", b"hmac"),
("label=b'Netflix'", b"Netflix"),
("label=b'MSL'", b"MSL"),
("label=b'session'", b"session"),
]
nist_contexts: list[tuple[str, bytes]] = [
("ctx=b''", b""),
("ctx=b'\\x00'", b"\x00"),
]
for label_name, label_val in nist_labels:
for ctx_name, ctx_val in nist_contexts:
try:
derived = nist_counter_kdf(DH_SHARED_SECRET, label_val, ctx_val, 48)
lbl = f"NIST-KDF-HMAC-SHA256 {label_name} {ctx_name} len=48"
check_any(lbl, derived)
except Exception:
pass
print()
print("=" * 70)
print("4. TLS PRF (HMAC-SHA256 ベース)")
print("=" * 70)
def tls_prf(secret: bytes, label: bytes, seed: bytes, length: int) -> bytes:
"""TLS 1.2 PRF (P_SHA256)"""
label_seed = label + seed
def hmac_sha256(key: bytes, msg: bytes) -> bytes:
return hmac_mod.new(key, msg, hashlib.sha256).digest()
def p_hash(key: bytes, seed: bytes, length: int) -> bytes:
result = b""
a = seed
while len(result) < length:
a = hmac_sha256(key, a)
result += hmac_sha256(key, a + seed)
return result[:length]
return p_hash(secret, label_seed, length)
tls_labels: list[tuple[str, bytes]] = [
("b'master secret'", b"master secret"),
("b'key expansion'", b"key expansion"),
("b'client finished'", b"client finished"),
("b''", b""),
("b'MSL'", b"MSL"),
("b'Netflix'", b"Netflix"),
("b'session'", b"session"),
]
tls_seeds: list[tuple[str, bytes]] = [
("seed=b''", b""),
("seed=b'\\x00'*32", b"\x00" * 32),
]
for lbl_name, lbl_val in tls_labels:
for seed_name, seed_val in tls_seeds:
try:
derived = tls_prf(DH_SHARED_SECRET, lbl_val, seed_val, 48)
label_str = f"TLS-PRF label={lbl_name} {seed_name} len=48"
check_any(label_str, derived)
except Exception:
pass
print()
print("=" * 70)
print("5. SHA-256/SHA-512 の直接切り出し (全組み合わせ)")
print("=" * 70)
# SHA-256 から直接 enc_key を探す
h256 = hashlib.sha256(DH_SHARED_SECRET).digest()
h384 = hashlib.sha384(DH_SHARED_SECRET).digest()
h512 = hashlib.sha512(DH_SHARED_SECRET).digest()
for name, digest in [("SHA-256", h256), ("SHA-384", h384), ("SHA-512", h512)]:
print(f"{name}(shared_secret) = {digest.hex()}")
for enc in ALL_ENC_KEYS:
for offset in range(len(digest) - 15):
if digest[offset : offset + 16] == enc:
print(f" [ENC MATCH offset={offset}] enc={enc.hex()}")
print()
print("=" * 70)
print("6. Netflix MSL Java 実装の既知パターン試行")
print("=" * 70)
# Netflix MSL Java 実装では JWK_RSA を使うが、
# iOS DH の場合の参考として: DH shared secret を SHA-384 ハッシュして truncate
# enc_key = SHA-384[:16], hmac_key = SHA-384[16:48]
h384_bytes = hashlib.sha384(DH_SHARED_SECRET).digest()
print(f"SHA-384[:16] = {h384_bytes[:16].hex()}")
print(f"SHA-384[16:48] = {h384_bytes[16:48].hex()}")
for enc in ALL_ENC_KEYS:
if h384_bytes[:16] == enc:
print(f" [SHA-384[:16] ENC MATCH] {enc.hex()}")
for hmac_k in ALL_HMAC_KEYS:
if h384_bytes[16:48] == hmac_k:
print(f" [SHA-384[16:48] HMAC MATCH] {hmac_k.hex()}")
# HKDF-SHA384 系
print()
print("HKDF-SHA384, out=48:")
hkdf = HKDF(algorithm=hashes.SHA384(), length=48, salt=None, info=b"")
d = hkdf.derive(DH_SHARED_SECRET)
print(f" derived = {d.hex()}")
check_enc_only("HKDF-SHA384 salt=None info=b'' len=48", d)
print()
print("=" * 70)
print("7. 先頭ゼロ補完バリアント (shared_secret が128バイト→256bitで奇数?)")
print("=" * 70)
# DH shared_secret を big-endian 整数として扱い、
# ゼロ補完して128バイトに揃える
# (すでに128バイトなので問題ないはずだが念のため)
print(f"shared_secret length: {len(DH_SHARED_SECRET)} bytes")
# 先頭に 0x00 を付けた 129 バイト版
ss_padded = b"\x00" + DH_SHARED_SECRET
h256_padded = hashlib.sha256(ss_padded).digest()
h384_padded = hashlib.sha384(ss_padded).digest()
print(f"SHA-256(b'\\x00' + shared_secret) = {h256_padded.hex()}")
print(f"SHA-384(b'\\x00' + shared_secret) = {h384_padded.hex()}")
for enc in ALL_ENC_KEYS:
if h256_padded[:16] == enc:
print(f" [MATCH] SHA-256(padded)[:16] == {enc.hex()}")
if h384_padded[:16] == enc:
print(f" [MATCH] SHA-384(padded)[:16] == {enc.hex()}")
# HKDF with padded shared_secret
for hash_name, hash_alg in HASH_ALGS.items():
for info_name, info_val in [("b''", b""), ("b'enc'", b"enc"), ("b'session'", b"session")]:
for salt_val, salt_name in [(None, "None"), (b"", "b''"), (b"\x00" * 32, "zeros32")]:
try:
hkdf = HKDF(
algorithm=hash_alg,
length=48,
salt=salt_val,
info=info_val,
)
derived = hkdf.derive(ss_padded)
label = f"HKDF(padded_ss) hash={hash_name} salt={salt_name} info={info_name}"
check_any(label, derived)
except Exception:
pass
print()
print("=" * 70)
print("結果サマリー")
print("=" * 70)
if found:
print(f"一致したパラメータ ({len(found)}件):")
for f in found:
print(f" - {f}")
else:
print("一致するパラメータは見つかりませんでした")
print()
print("デバッグ情報: 各ハッシュの先頭16バイト vs ALL_ENC_KEYS")
for hash_fn, name in [
(hashlib.sha256, "SHA-256"),
(hashlib.sha384, "SHA-384"),
(hashlib.sha512, "SHA-512"),
]:
h = hash_fn(DH_SHARED_SECRET).digest()
print(f" {name}[:16] = {h[:16].hex()}")
print(f" target enc keys: {[k.hex() for k in ALL_ENC_KEYS]}")
+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env python3
"""
DH 共有秘密の整合性確認 + 追加の HKDF パターン探索
"""
import hashlib
import hmac as hmac_mod
import json
import struct
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF, HKDFExpand
with open("/home/vscode/app/raws/msl_keys.json") as f:
keys = json.load(f)
DH_PRIV = int(keys["dh_priv_key"], 16)
DH_PUB = int(keys["dh_pub_key"], 16)
DH_SHARED = bytes.fromhex(keys["dh_shared_secret"])
DH_P = int(keys["dh_p"], 16)
DH_G = int(keys["dh_g"], 16)
print(f"DH p = {hex(DH_P)[:20]}... ({DH_P.bit_length()} bits)")
print(f"DH g = {DH_G}")
print(f"priv = {keys['dh_priv_key'][:20]}...")
print(f"pub = {keys['dh_pub_key'][:20]}...")
print(f"shared= {keys['dh_shared_secret'][:20]}...")
print()
# DH 公開鍵の検証: g^priv mod p == pub?
computed_pub = pow(DH_G, DH_PRIV, DH_P)
computed_pub_hex = hex(computed_pub)[2:].zfill(256) # 128 bytes = 256 hex chars
print(f"computed pub = {computed_pub_hex[:20]}...")
print(f"stored pub = {keys['dh_pub_key'][:20]}...")
print(f"DH pub key MATCH: {computed_pub_hex == keys['dh_pub_key']}")
print()
# DH shared secret の検証: dh_pub^priv mod p == shared?
# (この場合、dh_pub はサーバーの公開鍵であるべきだが、
# msl_keys.json にはサーバーの公開鍵が記録されていない)
# 代わりに: server_pub^priv mod p == shared を逆算して server_pub を求める
# これは困難なので、代わりに shared_secret の整合性を別の方法で確認
# dh_pub (クライアント公開鍵) を使って検算
# もし shared_secret = dh_pub^priv mod p なら...
computed_ss_from_client_pub = pow(DH_PUB, DH_PRIV, DH_P)
computed_ss_hex = hex(computed_ss_from_client_pub)[2:].zfill(256)
print(f"client_pub^priv mod p = {computed_ss_hex[:20]}...")
print(f"dh_shared_secret = {keys['dh_shared_secret'][:20]}...")
print()
# OpenSSL DH_compute_key の出力形式
# DH_compute_key は big-endian bytes, leading zeros なし
# shared secret が 1024-bit の場合: 最大 128 bytes (leading zeros は省略)
shared_int = int(keys["dh_shared_secret"], 16)
print(f"shared_secret as int = {shared_int}")
print(f"shared_secret bit length = {shared_int.bit_length()}")
print()
# shared_secret を 128 bytes (1024-bit) に zero-pad
shared_padded = shared_int.to_bytes(128, "big")
print(f"shared_secret (128B padded) = {shared_padded.hex()[:20]}...")
print(f"same as original: {shared_padded == DH_SHARED}")
print()
# --- 全 enc_key/hmac_key 候補 ---
ALL_ENC_KEYS = [bytes.fromhex(e["key"]) for e in keys["aes_key_history"]]
ALL_HMAC_KEYS = [bytes.fromhex(e["key"]) for e in keys["hmac_key_history"]]
ALL_ENC_UNIQUE = list(dict.fromkeys(k.hex() for k in ALL_ENC_KEYS))
ALL_HMAC_UNIQUE = list(dict.fromkeys(k.hex() for k in ALL_HMAC_KEYS))
print(f"Unique enc_keys ({len(ALL_ENC_UNIQUE)}): {ALL_ENC_UNIQUE}")
print(f"Unique hmac_keys ({len(ALL_HMAC_UNIQUE)}): {ALL_HMAC_UNIQUE}")
print()
found = []
def check_any_48(label: str, derived: bytes) -> bool:
for enc_hex in ALL_ENC_UNIQUE:
enc = bytes.fromhex(enc_hex)
for hmac_hex in ALL_HMAC_UNIQUE:
hmac_k = bytes.fromhex(hmac_hex)
if len(derived) >= 48:
combined_eh = enc + hmac_k
combined_he = hmac_k[:16] + enc
if derived[:48] == combined_eh:
msg = f"[ENC+HMAC MATCH] {label} enc={enc_hex[:8]} hmac={hmac_hex[:8]}"
print(msg)
found.append(msg)
return True
if len(derived) >= 16 and derived[:16] == enc:
msg = f"[ENC_ONLY] {label} enc={enc_hex[:8]} rest={derived[16:32].hex()}"
print(msg)
found.append(msg)
return True
return False
# ハッシュアルゴリズム
HASH_ALGS = {
"SHA-1": hashes.SHA1(),
"SHA-256": hashes.SHA256(),
"SHA-384": hashes.SHA384(),
"SHA-512": hashes.SHA512(),
}
INPUT_VARIANTS: list[tuple[str, bytes]] = [
("shared_raw", DH_SHARED),
("shared_padded_128", shared_padded),
("shared_int_be", shared_int.to_bytes(128, "big")),
]
# nonce: key_response_data の sub-key 9 = サーバー nonce
# (msl_cbor_key_exchange_analysis.md より)
# appboot レスポンス: サーバー nonce = e73104a8f4a9ed430d90a330d7978432
# クライアント nonce: a97e47477522ab39e39b322bdf818031
SERVER_NONCE = bytes.fromhex("e73104a8f4a9ed430d90a330d7978432")
CLIENT_NONCE = bytes.fromhex("a97e47477522ab39e39b322bdf818031")
print("=" * 70)
print("1. nonce をソルトまたは info に使った HKDF")
print("=" * 70)
extra_salts: list[tuple[str, bytes | None]] = [
("salt=None", None),
("salt=b''", b""),
("salt=server_nonce", SERVER_NONCE),
("salt=client_nonce", CLIENT_NONCE),
("salt=server+client_nonce", SERVER_NONCE + CLIENT_NONCE),
("salt=client+server_nonce", CLIENT_NONCE + SERVER_NONCE),
("salt=zeros_16", b"\x00" * 16),
("salt=zeros_32", b"\x00" * 32),
]
extra_infos: list[tuple[str, bytes]] = [
("info=b''", b""),
("info=server_nonce", SERVER_NONCE),
("info=client_nonce", CLIENT_NONCE),
("info=s+c_nonce", SERVER_NONCE + CLIENT_NONCE),
("info=c+s_nonce", CLIENT_NONCE + SERVER_NONCE),
("info=b'enc'", b"enc"),
("info=b'hmac'", b"hmac"),
("info=b'session'", b"session"),
("info=b'Netflix'", b"Netflix"),
("info=b'MSL'", b"MSL"),
("info=ESN_bytes", b"NFAPPL-02-IPHONE9=1"),
]
OUTPUT_LENGTHS = [16, 32, 48, 64, 80, 128]
for hash_name, hash_alg in HASH_ALGS.items():
for salt_name, salt_val in extra_salts:
for info_name, info_val in extra_infos:
for input_name, input_val in INPUT_VARIANTS:
for out_len in OUTPUT_LENGTHS:
try:
hkdf = HKDF(
algorithm=hash_alg,
length=out_len,
salt=salt_val,
info=info_val,
)
derived = hkdf.derive(input_val)
label = f"HKDF({input_name}) hash={hash_name} {salt_name} {info_name} len={out_len}"
check_any_48(label, derived)
except Exception:
pass
print()
print("=" * 70)
print("2. HKDFExpand with nonce as info")
print("=" * 70)
for hash_name, hash_alg in HASH_ALGS.items():
for info_name, info_val in extra_infos:
for input_name, input_val in INPUT_VARIANTS:
for out_len in OUTPUT_LENGTHS:
try:
hkdf_expand = HKDFExpand(
algorithm=hash_alg,
length=out_len,
info=info_val,
)
derived = hkdf_expand.derive(input_val)
label = f"HKDFExpand({input_name}) hash={hash_name} {info_name} len={out_len}"
check_any_48(label, derived)
except Exception:
pass
print()
print("=" * 70)
print("3. HMAC-SHA256 ベース KDF with nonces")
print("=" * 70)
hmac_keys_for_kdf: list[tuple[str, bytes]] = [
("key=shared", DH_SHARED),
("key=SHA256(shared)", hashlib.sha256(DH_SHARED).digest()),
("key=server_nonce", SERVER_NONCE),
("key=client_nonce", CLIENT_NONCE),
("key=zeros_32", b"\x00" * 32),
]
hmac_msgs: list[tuple[str, bytes]] = [
("msg=b''", b""),
("msg=server_nonce", SERVER_NONCE),
("msg=client_nonce", CLIENT_NONCE),
("msg=s+c_nonce", SERVER_NONCE + CLIENT_NONCE),
("msg=shared", DH_SHARED),
("msg=b'\\x00'", b"\x00"),
("msg=b'\\x01'", b"\x01"),
]
for key_name, key_val in hmac_keys_for_kdf:
for msg_name, msg_val in hmac_msgs:
h = hmac_mod.new(key_val, msg_val, hashlib.sha256).digest()
label = f"HMAC-SHA256 {key_name} {msg_name}"
check_any_48(label, h + b"\x00" * 16)
print()
print("=" * 70)
print("4. ハッシュ (nonce concatenation 込み)")
print("=" * 70)
hash_inputs: list[tuple[str, bytes]] = [
("SHA256(shared)", hashlib.sha256(DH_SHARED).digest()),
("SHA384(shared)", hashlib.sha384(DH_SHARED).digest()),
("SHA512(shared)", hashlib.sha512(DH_SHARED).digest()),
("SHA256(shared+server_nonce)", hashlib.sha256(DH_SHARED + SERVER_NONCE).digest()),
("SHA256(shared+client_nonce)", hashlib.sha256(DH_SHARED + CLIENT_NONCE).digest()),
("SHA256(server_nonce+shared)", hashlib.sha256(SERVER_NONCE + DH_SHARED).digest()),
("SHA256(client_nonce+shared)", hashlib.sha256(CLIENT_NONCE + DH_SHARED).digest()),
("SHA384(shared+s_nonce)", hashlib.sha384(DH_SHARED + SERVER_NONCE).digest()),
("SHA384(shared+c_nonce)", hashlib.sha384(DH_SHARED + CLIENT_NONCE).digest()),
("SHA512(shared+s_nonce)", hashlib.sha512(DH_SHARED + SERVER_NONCE).digest()),
]
for name, digest in hash_inputs:
for offset in range(0, max(1, len(digest) - 47), 1):
candidate = digest[offset : offset + 48]
if len(candidate) >= 16:
check_any_48(f"{name}[{offset}:]", candidate)
print()
print("=" * 70)
print("5. appboot response key 33.6 (96 bytes) の AES-CBC 復号試行")
print("=" * 70)
# docs/spec/msl_cbor_key_exchange_analysis.md より:
# レスポンス key 33.6 = 96 bytes:
# CT(64B) = bb73317f907f7a5a3f924bece878d6a6 8db3b8f354d2207a224a323297523f58 2d72dfb28ea593b584d096c861561be8 b7d72ef4dc404e076943130aa0303200
# HMAC(32B) = af5d720284876f9b1c076aacc2ad7fc8 6b5a242b0cf9beb28b2a87ad00f3db38
# または:
# IV(16B) + CT(48B) + HMAC(32B)
RESPONSE_96 = bytes.fromhex(
"bb73317f907f7a5a3f924bece878d6a6"
"8db3b8f354d2207a224a323297523f58"
"2d72dfb28ea593b584d096c861561be8"
"b7d72ef4dc404e076943130aa0303200"
"af5d720284876f9b1c076aacc2ad7fc8"
"6b5a242b0cf9beb28b2a87ad00f3db38"
)
print(f"response_96 = {RESPONSE_96.hex()}")
print()
# AES-CBC 復号: IV = 先頭 16B, CT = 次の 48B (または 64B)
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
def aes_cbc_decrypt(key: bytes, iv: bytes, ct: bytes) -> bytes | None:
try:
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
dec = cipher.decryptor()
return dec.update(ct) + dec.finalize()
except Exception:
return None
# 仮説 A: CT(64B) を AES-CBC 復号して enc_key + hmac_key を得る
# 復号鍵: DH shared secret から直接導出した何かか?
# まず SHA-256(shared_secret) を鍵にして試す
print("5.1 CT=96[0:64], IV は何か? 復号鍵候補で試行")
CT_64 = RESPONSE_96[:64]
CT_48 = RESPONSE_96[16:64] # IV(16) + CT(48) の場合
IV_16 = RESPONSE_96[:16]
# SHA-256(shared_secret)[:16] を AES 鍵として使う
sha256_ss = hashlib.sha256(DH_SHARED).digest()
aes_keys_to_try: list[tuple[str, bytes]] = [
("SHA256(shared)[:16]", sha256_ss[:16]),
("SHA256(shared)[16:32]", sha256_ss[16:32]),
("SHA384(shared)[:16]", hashlib.sha384(DH_SHARED).digest()[:16]),
("SHA384(shared)[16:32]", hashlib.sha384(DH_SHARED).digest()[16:32]),
("SHA512(shared)[:16]", hashlib.sha512(DH_SHARED).digest()[:16]),
("shared[:16]", DH_SHARED[:16]),
("shared[16:32]", DH_SHARED[16:32]),
("server_nonce", SERVER_NONCE),
("client_nonce", CLIENT_NONCE),
("zeros_16", b"\x00" * 16),
]
ivs_to_try: list[tuple[str, bytes]] = [
("IV=response[:16]", RESPONSE_96[:16]),
("IV=zeros", b"\x00" * 16),
("IV=server_nonce", SERVER_NONCE),
("IV=client_nonce", CLIENT_NONCE),
("IV=SHA256(shared)[:16]", sha256_ss[:16]),
]
for key_name, aes_key in aes_keys_to_try:
for iv_name, iv_val in ivs_to_try:
# CT = 64 bytes (仮説 A)
pt = aes_cbc_decrypt(aes_key, iv_val, CT_64)
if pt:
# PKCS7 アンパッド
pad_len = pt[-1]
if 1 <= pad_len <= 16 and pt[-pad_len:] == bytes([pad_len]) * pad_len:
pt_unpad = pt[:-pad_len]
if len(pt_unpad) == 48:
enc_candidate = pt_unpad[:16]
hmac_candidate = pt_unpad[16:]
for enc_hex in ALL_ENC_UNIQUE:
if bytes.fromhex(enc_hex) == enc_candidate:
print(f"[DECRYPT MATCH enc] AES-CBC key={key_name} {iv_name}")
print(f" enc={enc_candidate.hex()} hmac={hmac_candidate.hex()}")
found.append(f"DECRYPT enc_match: {key_name} {iv_name}")
for hmac_hex in ALL_HMAC_UNIQUE:
if bytes.fromhex(hmac_hex) == hmac_candidate:
print(f"[DECRYPT MATCH hmac] AES-CBC key={key_name} {iv_name}")
found.append(f"DECRYPT hmac_match: {key_name} {iv_name}")
# CT = 48 bytes (仮説 B: IV=response[:16])
pt2 = aes_cbc_decrypt(aes_key, iv_val, CT_48)
if pt2:
pad_len = pt2[-1]
if 1 <= pad_len <= 16 and pt2[-pad_len:] == bytes([pad_len]) * pad_len:
pt_unpad = pt2[:-pad_len]
for enc_hex in ALL_ENC_UNIQUE:
if len(pt_unpad) >= 16 and bytes.fromhex(enc_hex) == pt_unpad[:16]:
print(f"[DECRYPT2 MATCH enc] AES-CBC(48B CT) key={key_name} {iv_name}")
print(f" pt={pt_unpad.hex()}")
found.append(f"DECRYPT2 enc_match: {key_name} {iv_name}")
print()
print("=" * 70)
print("6. appboot response の実際のファイルを探して解析")
print("=" * 70)
import os, glob
appboot_files = glob.glob("/home/vscode/app/raws/ios/*/raw/*appboot*")
print(f"appboot files found: {appboot_files}")
print()
print("=" * 70)
print("結果サマリー")
print("=" * 70)
if found:
print(f"一致 ({len(found)}件):")
for f in found:
print(f" - {f}")
else:
print("一致なし")
print()
print("デバッグ: SHA256(shared_secret) = ", hashlib.sha256(DH_SHARED).hexdigest())
print("デバッグ: 全 enc_key unique =", ALL_ENC_UNIQUE)
print()
print("NOTE: dh_shared_secret と session_enc_key が別セッションの可能性が高い")
print(" msl_keys.json timestamp: 2026-04-08T19:01:29")
print(" キャプチャファイル: 07:xx ~ 08:xx 台")
print(" → 別のセッションの鍵が記録されている可能性")