feat(appboot): add e2e test script and simplify iOSMslClient architecture

Corrects entity_auth_data CBOR structure based on 243 captured requests:
- scheme name is "MGK_APPID" (not "FAIRPLAY_MGK_APPID")
- apphmac is device_id_token UTF-8 bytes, not a computed HMAC
- devicetoken is base64.b64decode(device_id_token) raw bytes
- integer key 3 for ESN (not string key); no device_key_data or esn_prefix
- identity (key 33.8) is full ESN without "_3" suffix
- signature = HMAC-SHA256(sign_key_0, key_request_data_bytes) only

iOSAppbootParams simplified: removes device_key_data, apphmac, devicetoken,
renewable, capabilities, esn_prefix. Adds optional device_id_token for
subsequent sessions. CBOR building moved inline into iOSMslClient.

New tools/test_appboot_e2e.py performs the full chain: TFIT emulation ->
Phase 3 KDF -> DH keygen -> CBOR build -> POST appboot.netflix.com ->
response parse. Handles both CBOR success and JSON MSL error responses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
tkgstrator
2026-04-09 15:24:01 +00:00
co-authored by Claude Sonnet 4.6
parent 70fb7279d0
commit 9be376736d
2 changed files with 1077 additions and 200 deletions
+302 -200
View File
@@ -5,12 +5,14 @@ appboot から MSL セッション鍵確立までの End-to-end フローを実
## フロー概要 (msl_key_relationship.md §2)
Phase 0 (前提): ESN → TFIT-WB-AES → enc_key_0, sign_key_0
(tools/emulate_tfit.py または Frida キャプチャで取得)
(tools/emulate_tfit.py で導出)
Phase 3: kdf_renew(PSK, enc_key_0, sign_key_0, nonce) → enc_key_1, sign_key_1
Phase 1: DH 鍵ペア生成 → appboot リクエスト構築 → POST appboot.netflix.com
署名 = HMAC-SHA256(sign_key_0, key_request_data_bytes)
レスポンス解析 → server_scheme_data (96B), server_nonce (16B)
レスポンスヘッダー → x-netflix-deviceidtoken
Phase 2: DH_compute_key(server_pub_key, client_priv_key)
→ HMAC-SHA384(48B_KEY, 0x00 || shared_secret) → enc_key, bootstrap_key
@@ -19,6 +21,27 @@ appboot から MSL セッション鍵確立までの End-to-end フローを実
Phase 5: MSL 通信 (AES-128-CBC + HMAC-SHA256)
## 実測で確定した entity_auth_data 構造 (243 リクエストから確認)
{
35: {
"apphmac": bytes # 初回は b""; 以降は device_id_token.encode("utf-8")
"appid": str # IOS_APPID 定数
"appkeyversion": int # 1
"devicetoken": bytes # 初回は b""; 以降は base64.b64decode(device_id_token)
3: str # 完全 ESN (整数キー)
},
30: "MGK_APPID"
}
- apphmac は HMAC 値ではなく deviceIdToken 文字列の UTF-8 バイト列
- devicetoken は deviceIdToken を Base64 デコードした 216B raw bytes
- 両フィールドの出所は同一: x-netflix-deviceidtoken レスポンスヘッダー
- 初回リクエストでは両フィールドとも b"" (空バイト)
- entity_auth_data の scheme 名は "MGK_APPID" (旧 "FAIRPLAY_MGK_APPID" は誤り)
- key_request_data の identity (key 33.8) は完全 ESN のみ (サフィックスなし)
- 署名対象は key_request_data_bytes のみ (ヘッダーは含まない)
## 制約
- key 33.6 の TFIT エンコード部 (DH 公開鍵 128B → session_region 172B) は
@@ -26,97 +49,91 @@ appboot から MSL セッション鍵確立までの End-to-end フローを実
現在は session_region をコンストラクタパラメータとして受け取る。
- server_scheme_data (96B) から server DH 公開鍵の抽出は未解明。
現在は server_scheme_data を直接パラメータとして受け取る。
## TODO (Frida キャプチャが必要な値)
- enc_key_0 / sign_key_0: TFIT エミュレーション (emulate_tfit.py) で導出可能
- devicetoken: Frida/Tweak `AppbootKeyExtract` でキャプチャ
- apphmac: NetflixCrypto.compute_apphmac(enc_key_0, sign_key_0) で計算可能
- device_key_data: Frida/Tweak `AppbootKeyExtract` でキャプチャ (~6,576 bytes)
- session_region: TFIT エミュレーション (emulate_tfit.py) または Frida キャプチャ
- s1, s2, s3: Frida キャプチャで取得
- renewable / capabilities: 既知の固定値 (ios_auth_manifest_license_flow.md §2.1)
- server_dh_pub_key: server_scheme_data (96B) のフォーマット解明後に自動抽出可能
"""
from __future__ import annotations
import base64
import hashlib
import hmac as hmac_mod
import os
from dataclasses import dataclass, field
import cbor2
import requests
from netflix_msl.cbor_decoder import CborMslDecoder
from netflix_msl.cbor_encoder import CborMslEncoder
from netflix_msl.constants import IOS_APPBOOT_ENDPOINT, IOS_APPID, IOS_APPKEYVERSION
from netflix_msl.constants import (
IOS_APPBOOT_ENDPOINT,
IOS_APPID,
IOS_APPKEYVERSION,
IOS_KEY336_DEVICE_HEADER,
)
from netflix_msl.crypto import NetflixCrypto, SessionKeys
# ---------------------------------------------------------------------------
# CBOR 数値キー定数
# ---------------------------------------------------------------------------
_ENTITY_SCHEME = 30
_ENTITY_AUTH_DATA = 35
_KEYEX_SCHEME = 6
_KEYEX_KEYDATA = 7
_KEYEX_IDENTITY = 8
_KEYEX_NONCE = 9
_KEY_ENTITY_AUTH = 34
_KEY_KEY_EXCHANGE = 33
_KEY_MESSAGE_SIG = 16
# key 33.6 XOR nonce 変形マスク (165 サンプル全てで一致)
_N2_MASK = bytes([0xF4, 0x1B, 0x00, 0x00, 0x00, 0x00, 0x00])
_N3_MASK = bytes([0xF4, 0x1B, 0x00, 0x18, 0x1B, 0x00, 0x00])
_TAIL_MASK = bytes([0xF3, 0x1C, 0x07, 0x1F])
@dataclass
class iOSAppbootParams:
"""appboot リクエストに必要なデバイス固有パラメータ.
全フィールドは Frida/Tweak キャプチャまたは TFIT エミュレーションで取得する。
enc_key_0 / sign_key_0 は tools/emulate_tfit.py で ESN から導出する。
session_region / s1 / s2 / s3 は TFIT エミュレーション実装後に自動導出予定。
"""
# Phase 0 MGK (TFIT エミュレーションで導出可能)
# Phase 0 MGK (tools/emulate_tfit.py で導出可能)
enc_key_0: bytes
"""Phase 0 MGK 暗号化鍵 (16 bytes). TFIT エミュレーションで導出可能."""
"""Phase 0 MGK 暗号化鍵 (16 bytes). tools/emulate_tfit.py で導出。"""
sign_key_0: bytes
"""Phase 0 MGK 署名鍵 (32 bytes). TFIT エミュレーションで導出可能."""
# FAIRPLAY_MGK_APPID entity_auth_data
appid: str
"""アプリ識別子 UUID (e.g. 'a2becfec-b286-535c-b884-903a384caee6').
TODO: Frida キャプチャで取得が必要 (バイナリに埋め込まれている可能性あり)."""
devicetoken: str
"""デバイストークン (hex string).
TODO: Frida/Tweak AppbootKeyExtract でキャプチャ."""
apphmac: str
"""アプリ認証 HMAC-SHA256 (hex string).
NetflixCrypto.compute_apphmac(enc_key_0, sign_key_0).hex() で計算可能."""
device_key_data: bytes
"""デバイス固有鍵データ (~6,576 bytes).
TODO: Frida/Tweak AppbootKeyExtract でキャプチャ."""
"""Phase 0 MGK 署名鍵 (32 bytes). tools/emulate_tfit.py で導出。"""
# key 33.6 session_region
session_region: bytes
"""key 33.6 平文の [128:300] セッション領域 (172 bytes).
TFIT エミュレーション (tools/emulate_tfit.py) または Frida キャプチャで取得.
TODO: emulate_tfit.py による自動導出が未実装."""
TODO: emulate_tfit.py による自動導出が未実装。"""
# key 33.6 separators
s1: bytes
"""key 33.6 の 9B セッション固定セパレータ (pt[307:316]).
TODO: Frida キャプチャで取得が必要."""
TODO: Frida キャプチャで取得が必要。"""
s2: bytes
"""key 33.6 の 9B セッション固定セパレータ (pt[323:332]).
TODO: Frida キャプチャで取得が必要."""
TODO: Frida キャプチャで取得が必要。"""
s3: bytes
"""key 33.6 の 9B セパレータ (pt[339:348]). byte[5] は per-request counter.
TODO: Frida キャプチャで取得が必要."""
# MSL ヘッダー固定値
renewable: bytes
"""capabilities key 16 の固定バイト列 (44 bytes).
既知の固定値: 010100810001012022b1205c03559bc416af500d517f2c15463fc04717f8fb38b40c5ddce4e24fe11cc01955
TODO: キャプチャから取得した値を使用すること."""
capabilities: dict
"""capabilities dict (key 15 の値).
{10: bytes, 11: int, 12: int, 13: int, 14: int, 94: {95: True}}
TODO: キャプチャから取得した値を使用すること."""
TODO: Frida キャプチャで取得が必要。"""
# オプション
appid: str = IOS_APPID
appkeyversion: int = IOS_APPKEYVERSION
esn_prefix: str = ""
scheme_suffix: str = "_3"
# 継続セッション用 (初回は None)
device_id_token: str | None = None
"""x-netflix-deviceidtoken ヘッダー値 (Base64 文字列).
初回リクエストは None。以降は前回レスポンスの値をセットする。
- apphmac = device_id_token.encode("utf-8") として entity_auth_data に設定
- devicetoken = base64.b64decode(device_id_token) として entity_auth_data に設定"""
@dataclass
@@ -151,21 +168,18 @@ class iOSSessionState:
class iOSMslClient:
"""iOS Netflix MSL appboot から暗号化 MSL リクエストまでのオーケストレーター.
使用例 (Frida/Tweak でキャプチャした値を使用):
使用例:
from tools.emulate_tfit import compute_mgk # または外部から値を渡す
params = iOSAppbootParams(
enc_key_0=bytes.fromhex("0817065e..."),
enc_key_0=bytes.fromhex("0817065e..."), # TFIT エミュレーション結果
sign_key_0=bytes.fromhex("91f752f7..."),
appid="a2becfec-b286-535c-b884-903a384caee6",
devicetoken="0608a1b7...", # TODO: Frida キャプチャ
apphmac=NetflixCrypto.compute_apphmac(enc_key_0, sign_key_0).hex(),
device_key_data=b"...", # TODO: Frida キャプチャ (~6576B)
session_region=b"...", # TODO: TFIT エミュレーション or Frida
s1=b"...", # TODO: Frida キャプチャ
s2=b"...", # TODO: Frida キャプチャ
s3=b"...", # TODO: Frida キャプチャ
renewable=bytes.fromhex("010100..."),
capabilities={10: b"...", ...},
session_region=b"\\x00" * 172, # TODO: TFIT エミュレーション
s1=b"\\x00" * 9, # TODO: Frida キャプチャ
s2=b"\\x00" * 9,
s3=b"\\x00" * 9,
device_id_token=None, # 初回は None
)
client = iOSMslClient(
@@ -174,7 +188,7 @@ class iOSMslClient:
)
session = client.perform_appboot()
# session.session_keys.enc_key_1 で以降の MSL リクエストを暗号化
# session.device_id_token を次回 iOSAppbootParams.device_id_token に設定する
"""
def __init__(
@@ -187,7 +201,7 @@ class iOSMslClient:
Args:
esn: PRV ESN (e.g. "NFAPPL-02-IPHONE9=1-AD0455...")
appboot_params: デバイス固有パラメータ (Frida キャプチャ値)
appboot_params: デバイス固有パラメータ
timeout: HTTP タイムアウト秒数 (default 30)
"""
self.esn = esn
@@ -219,11 +233,12 @@ class iOSMslClient:
手順:
1. DH 鍵ペアを生成
2. appboot CBOR リクエストを構築
2. entity_auth_data + key_request_data CBOR を構築
3. POST appboot.netflix.com/{ESN_PREFIX}?keyVersion=1
4. レスポンスを解析して server_scheme_data と server_nonce を抽出
5. DH 共有秘密を計算
6. Phase 2/3 KDF で SessionKeys を導出
5. x-netflix-deviceidtoken ヘッダーを記録
6. DH 共有秘密を計算 (server DH 公開鍵抽出が未実装のため暫定)
7. Phase 2/3 KDF で SessionKeys を導出
Returns:
iOSSessionState: セッション鍵・Cookie・deviceIdToken を含む状態
@@ -239,28 +254,11 @@ class iOSMslClient:
# --- Step 2: appboot CBOR リクエストを構築 ---
print("[appboot] Step 2: CBOR リクエストを構築中...")
encoder = CborMslEncoder(self.crypto)
cbor_message, k9_xor_nonce, nonce_7b = encoder.build_appboot_request(
esn=self.esn,
dh_pub_key=dh_pub_key,
appid=self.params.appid,
devicetoken=self.params.devicetoken,
apphmac=self.params.apphmac,
device_key_data=self.params.device_key_data,
session_region=self.params.session_region,
s1=self.params.s1,
s2=self.params.s2,
s3=self.params.s3,
renewable=self.params.renewable,
capabilities=self.params.capabilities,
appkeyversion=self.params.appkeyversion,
esn_prefix=self.params.esn_prefix,
scheme_suffix=self.params.scheme_suffix,
)
cbor_message, k9_xor_nonce, nonce_7b = self._build_appboot_cbor()
print(f" Request size: {len(cbor_message)} bytes")
# --- Step 3: POST appboot ---
esn_prefix = self._extract_esn_prefix(self.esn)
esn_prefix = _extract_esn_prefix(self.esn)
url = IOS_APPBOOT_ENDPOINT + esn_prefix
params = {"keyVersion": str(self.params.appkeyversion)}
@@ -280,8 +278,7 @@ class iOSMslClient:
# --- Step 4: レスポンスを解析 ---
print("[appboot] Step 4: レスポンスを解析中...")
decoder = CborMslDecoder(self.crypto)
parsed = decoder.parse_appboot_response(resp.content)
parsed = _parse_appboot_response(resp.content)
server_scheme_data = parsed.get("server_scheme_data")
server_nonce = parsed.get("server_nonce")
@@ -295,13 +292,13 @@ class iOSMslClient:
if server_nonce:
print(f" server_nonce: {server_nonce.hex()}")
# Cookie と deviceIdToken を抽出
nfvdid_cookie = self._extract_nfvdid_cookie(resp)
# Step 5: Cookie と deviceIdToken を抽出
nfvdid_cookie = _extract_nfvdid_cookie(resp)
device_id_token = resp.headers.get("x-netflix-deviceidtoken", "")
if nfvdid_cookie:
print(f" nfvdid cookie: {nfvdid_cookie[:32]}...")
if device_id_token:
print(f" deviceIdToken: {device_id_token[:32]}...")
print(f" deviceIdToken: {device_id_token[:40]}...")
if server_scheme_data is None:
raise ValueError(
@@ -312,13 +309,12 @@ class iOSMslClient:
"server_nonce (key 33.9) が appboot レスポンスに存在しない"
)
# --- Step 5: DH 共有秘密を計算 ---
# --- Step 6: DH 共有秘密を計算 ---
# TODO: server_scheme_data (96B) から server DH 公開鍵 (128B) を抽出するロジックが未解明。
# 現在は server_scheme_data の構造が [IV(16B)][CT(48B)][HMAC(32B)] と推定されているが、
# 実際の DH 公開鍵の位置・抽出方法は msl_cbor_key_exchange_analysis.md §5 参照。
# Frida フック (DH_compute_key の入力引数) で server DH 公開鍵を直接キャプチャすること。
print("[appboot] Step 5: DH 共有秘密を計算中...")
server_dh_pub_key = self._extract_server_dh_pub_key(server_scheme_data)
# 正確な抽出方法は Frida フック (DH_compute_key の peer_pub 引数) で
# server DH 公開鍵を直接キャプチャして解明する必要がある。
print("[appboot] Step 6: DH 共有秘密を計算中...")
server_dh_pub_key = _extract_server_dh_pub_key(server_scheme_data)
dh_shared_secret = NetflixCrypto.compute_dh_shared_secret(
peer_public=server_dh_pub_key,
private_key=dh_priv_key,
@@ -327,8 +323,8 @@ class iOSMslClient:
f" dh_shared_secret: {dh_shared_secret[:16].hex()}... ({len(dh_shared_secret)}B)"
)
# --- Step 6: Phase 2/3 KDF でセッション鍵を導出 ---
print("[appboot] Step 6: セッション鍵を導出中 (Phase 3 → Phase 2)...")
# --- Step 7: Phase 2/3 KDF でセッション鍵を導出 ---
print("[appboot] Step 7: セッション鍵を導出中 (Phase 3 → Phase 2)...")
session_keys = NetflixCrypto.derive_full_key_chain(
enc_key_0=self.params.enc_key_0,
sign_key_0=self.params.sign_key_0,
@@ -358,113 +354,99 @@ class iOSMslClient:
print("[appboot] 完了")
return self.session_state
def build_encrypted_msl_request(
self,
payload_body: dict | list | str,
endpoint: str,
keyid: str | None = None,
compress: bool = False,
) -> bytes:
"""暗号化 MSL リクエストを構築する.
# ------------------------------------------------------------------
# 内部: CBOR メッセージ構築
# ------------------------------------------------------------------
perform_appboot() 完了後にのみ呼び出せる。
enc_key_1 / sign_key_1 でペイロードを暗号化・署名する。
def _build_appboot_cbor(self) -> tuple[bytes, bytes, bytes]:
"""appboot CBOR メッセージを構築する.
Args:
payload_body: リクエスト本体 (dict/list/str)
endpoint: MSL エンドポイント URL (ログ用)
keyid: 鍵 ID。None の場合は "{ESN}_3" を使用。
compress: True の場合 gzip 圧縮してから暗号化
entity_auth_data 構造 (実測フォーマット):
{
35: {
"apphmac": bytes # 初回は b""; 以降は device_id_token.encode("utf-8")
"appid": str
"appkeyversion": int
"devicetoken": bytes # 初回は b""; 以降は base64.b64decode(device_id_token)
3: str # 完全 ESN (整数キー)
},
30: "MGK_APPID"
}
key_request_data 構造:
{
6: bytes(352B) # key 33.6 XOR 暗号化 scheme_data
7: bytes(0B) # master_token (空)
8: str # identity = 完全 ESN
9: bytes(16B) # k9_xor_nonce
}
署名: HMAC-SHA256(sign_key_0, key_request_data_bytes)
Returns:
CBOR エンコードされた暗号化 MSL リクエスト
Raises:
RuntimeError: perform_appboot() が未完了の場合
(cbor_message, k9_xor_nonce, nonce_7b)
"""
if self.session_state is None:
raise RuntimeError(
"perform_appboot() を先に実行してセッションを確立すること"
)
# --- apphmac / devicetoken を決定 ---
if self.params.device_id_token:
apphmac = self.params.device_id_token.encode("utf-8")
try:
pad = 4 - len(self.params.device_id_token) % 4
padded = self.params.device_id_token + ("=" * pad if pad != 4 else "")
devicetoken = base64.b64decode(padded)
except Exception:
devicetoken = b""
else:
apphmac = b""
devicetoken = b""
encoder = CborMslEncoder(self.crypto)
payload_bytes_raw = CborMslEncoder.build_payload_json(
message_id=self._generate_message_id(),
body=payload_body,
compress=compress,
# --- entity_auth_data を構築 ---
auth_data: dict = {
"apphmac": apphmac,
"appid": self.params.appid,
"appkeyversion": self.params.appkeyversion,
"devicetoken": devicetoken,
3: self.esn, # 整数キー
}
entity_auth_data = {
_ENTITY_AUTH_DATA: auth_data,
_ENTITY_SCHEME: "MGK_APPID",
}
# --- key 33.9 nonce と 7B nonce を生成 ---
k9_xor_nonce = os.urandom(16)
nonce_7b = os.urandom(7)
# --- key 33.6 scheme_data (352B) を構築 ---
scheme_data_enc = _build_key336_scheme_data(
session_region=self.params.session_region,
nonce_7b=nonce_7b,
s1=self.params.s1,
s2=self.params.s2,
s3=self.params.s3,
k9_xor_nonce=k9_xor_nonce,
)
_keyid = keyid or f"{self.esn}_3"
# --- key_request_data を構築 ---
key_request_data: dict = {
_KEYEX_SCHEME: scheme_data_enc,
_KEYEX_KEYDATA: b"", # master_token (新規セッション)
_KEYEX_IDENTITY: self.esn, # identity = 完全 ESN
_KEYEX_NONCE: k9_xor_nonce,
}
key_request_bytes = cbor2.dumps(key_request_data)
# ヘッダー構築 (entity_auth_data なし)
header_bytes = encoder.build_header(
renewable=self.params.renewable,
capabilities=self.params.capabilities,
)
# --- 署名: HMAC-SHA256(sign_key_0, key_request_data_bytes) ---
signature = hmac_mod.new(
self.params.sign_key_0, key_request_bytes, hashlib.sha256
).digest()
return encoder.build_encrypted_message(
header_bytes=header_bytes,
key_request_bytes=None,
payload_plaintext=payload_bytes_raw,
keyid=_keyid,
compress=False, # build_payload_json で既に対応済み
)
# ------------------------------------------------------------------
# 内部ヘルパー
# ------------------------------------------------------------------
@staticmethod
def _extract_esn_prefix(esn: str) -> str:
"""ESN から ESN プレフィックス部分を抽出する.
"NFAPPL-02-IPHONE9=1-AD0455..." → "NFAPPL-02-IPHONE9=1-"
"""
idx = esn.rfind("-")
return esn[: idx + 1] if idx != -1 else esn
@staticmethod
def _extract_nfvdid_cookie(resp: requests.Response) -> str:
"""Set-Cookie ヘッダーから nfvdid Cookie 値を抽出する."""
set_cookie = resp.headers.get("Set-Cookie", "")
if "nfvdid=" in set_cookie:
start = set_cookie.index("nfvdid=") + len("nfvdid=")
end = set_cookie.find(";", start)
return set_cookie[start:end] if end != -1 else set_cookie[start:]
# requests の cookies からも試みる
nfvdid = resp.cookies.get("nfvdid", "")
return nfvdid
@staticmethod
def _extract_server_dh_pub_key(server_scheme_data: bytes) -> bytes:
"""server_scheme_data (96B) からサーバー DH 公開鍵 (128B) を抽出する.
TODO: server_scheme_data の構造が解明されていないため、現在は暫定実装。
msl_cbor_key_exchange_analysis.md §5 の推定構造:
[IV(16B)][CT(48B)][HMAC(32B)]
サーバー DH 公開鍵は CT を dec_key で復号した後に得られると推定。
dec_key の由来は未解明。
正確な抽出方法は Frida フック (DH_compute_key の peer_pub 引数) で
server DH 公開鍵を直接キャプチャして解明する必要がある。
現在の実装:
server_scheme_data の先頭 96B をゼロパディングして 128B を返す。
実際のセッション確立には Frida キャプチャした値を使用すること。
"""
# TODO: 実際の DH 公開鍵抽出ロジックをここに実装する。
# 暫定: server_scheme_data の先頭 96B + 32B ゼロパディング
# これは正しい値ではない。Frida でキャプチャした server DH pub_key を使用すること。
if len(server_scheme_data) >= 128:
return server_scheme_data[:128]
return server_scheme_data.ljust(128, b"\x00")
@staticmethod
def _generate_message_id() -> int:
"""MSL メッセージ ID (rand in [0, 2^52)) を生成する."""
import random
return random.randint(0, 2**52)
# --- トップレベルメッセージを構築 ---
message = {
_KEY_ENTITY_AUTH: cbor2.dumps(entity_auth_data),
_KEY_KEY_EXCHANGE: key_request_bytes,
_KEY_MESSAGE_SIG: signature,
}
return cbor2.dumps(message), k9_xor_nonce, nonce_7b
# ------------------------------------------------------------------
# セッション状態アクセサ
@@ -484,3 +466,123 @@ class iOSMslClient:
def device_id_token(self) -> str:
"""x-netflix-deviceidtoken 値。perform_appboot() 前は空文字列。"""
return self.session_state.device_id_token if self.session_state else ""
# ---------------------------------------------------------------------------
# モジュールレベルヘルパー
# ---------------------------------------------------------------------------
def _build_key336_scheme_data(
session_region: bytes,
nonce_7b: bytes,
s1: bytes,
s2: bytes,
s3: bytes,
k9_xor_nonce: bytes,
) -> bytes:
"""key 33.6 scheme_data 352B を XOR 暗号化して返す.
NetflixCrypto.build_key336_scheme_data() と同じロジック。
"""
n = nonce_7b
n2 = bytes(a ^ b for a, b in zip(n, _N2_MASK))
n3 = bytes(a ^ b for a, b in zip(n, _N3_MASK))
tail = bytes(a ^ b for a, b in zip(n[:4], _TAIL_MASK))
plaintext = (
IOS_KEY336_DEVICE_HEADER + session_region + n + s1 + n2 + s2 + n3 + s3 + tail
)
assert len(plaintext) == 352, f"plaintext length {len(plaintext)} != 352"
return bytes(plaintext[i] ^ k9_xor_nonce[i % 16] for i in range(352))
def _extract_esn_prefix(esn: str) -> str:
"""ESN から ESN プレフィックス部分を抽出する.
"NFAPPL-02-IPHONE9=1-AD0455..." → "NFAPPL-02-IPHONE9=1-"
"""
idx = esn.rfind("-")
return esn[: idx + 1] if idx != -1 else esn
def _extract_nfvdid_cookie(resp: requests.Response) -> str:
"""Set-Cookie ヘッダーから nfvdid Cookie 値を抽出する."""
set_cookie = resp.headers.get("Set-Cookie", "")
if "nfvdid=" in set_cookie:
start = set_cookie.index("nfvdid=") + len("nfvdid=")
end = set_cookie.find(";", start)
return set_cookie[start:end] if end != -1 else set_cookie[start:]
return resp.cookies.get("nfvdid", "")
def _extract_server_dh_pub_key(server_scheme_data: bytes) -> bytes:
"""server_scheme_data (96B) からサーバー DH 公開鍵 (128B) を抽出する.
TODO: server_scheme_data の構造が解明されていないため、現在は暫定実装。
正確な抽出方法は Frida フック (DH_compute_key の peer_pub 引数) で
server DH 公開鍵を直接キャプチャして解明する必要がある。
現在の実装:
server_scheme_data の先頭 96B をゼロパディングして 128B を返す。
実際のセッション確立には Frida キャプチャした値を使用すること。
"""
if len(server_scheme_data) >= 128:
return server_scheme_data[:128]
return server_scheme_data.ljust(128, b"\x00")
def _parse_appboot_response(raw: bytes) -> dict:
"""appboot レスポンス CBOR を解析する.
Returns:
{
"server_scheme_data": bytes | None,
"server_nonce": bytes | None,
"scheme_id": str | None,
"status_flag": bytes | None,
"raw": dict,
}
"""
try:
top = cbor2.loads(raw)
except Exception as e:
raise ValueError(f"CBOR デコード失敗: {e}") from e
result: dict = {
"server_scheme_data": None,
"server_nonce": None,
"scheme_id": None,
"status_flag": None,
"raw": top,
}
krd_raw = top.get(_KEY_KEY_EXCHANGE)
if krd_raw is None:
return result
try:
krd = cbor2.loads(krd_raw) if isinstance(krd_raw, bytes) else krd_raw
except Exception:
return result
if not isinstance(krd, dict):
return result
v6 = krd.get(_KEYEX_SCHEME)
if isinstance(v6, bytes):
result["server_scheme_data"] = v6
v7 = krd.get(_KEYEX_KEYDATA)
if isinstance(v7, bytes):
result["status_flag"] = v7
v8 = krd.get(_KEYEX_IDENTITY)
if v8 is not None:
result["scheme_id"] = str(v8)
v9 = krd.get(_KEYEX_NONCE)
if isinstance(v9, bytes):
result["server_nonce"] = v9
return result
+775
View File
@@ -0,0 +1,775 @@
#!/usr/bin/env python3
"""test_appboot_e2e.py — iOS MSL appboot End-to-End テスト
ESN から MSL セッション鍵確立までの完全なチェーンをテストする。
## テストするフロー
Phase 0: ESN → TFIT エミュレーション → enc_key_0, sign_key_0
Phase 3: kdf_renew(PSK, enc_key_0, sign_key_0, nonce) → enc_key_1, sign_key_1
Phase 1: DH 鍵ペア生成 → entity_auth_data + key_request_data を構築
→ HMAC-SHA256(sign_key_0, key_request_data_bytes) で署名
→ POST appboot.netflix.com/{ESN_PREFIX}?keyVersion=1
Phase 2: server_scheme_data / server_nonce を解析
→ deviceIdToken を x-netflix-deviceidtoken ヘッダーから抽出
## 実測で確定した構造
entity_auth_data CBOR:
{
35: {
"apphmac": bytes # 初回は b"" (以降は compute_apphmac() 結果)
"appid": str # IOS_APPID 定数
"appkeyversion": int # 1
"devicetoken": bytes # 初回は b"" (以降は base64.b64decode(device_id_token))
3: str # 完全 ESN (整数キー)
},
30: "MGK_APPID"
}
key_request_data CBOR:
{
6: bytes(352B) # key 33.6 XOR 暗号化 scheme_data
9: bytes(16B) # key 33.9 XOR nonce
8: str # identity = 完全 ESN
7: bytes(0B) # master_token (新規セッション)
}
signature: HMAC-SHA256(sign_key_0, key_request_data_bytes)
## 使用法
uv run tools/test_appboot_e2e.py
uv run tools/test_appboot_e2e.py --esn <ESN>
uv run tools/test_appboot_e2e.py --no-proxy
uv run tools/test_appboot_e2e.py --device-id-token <B64_TOKEN> --devicetoken-hex <HEX>
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac as hmac_mod
import os
import subprocess
import sys
import cbor2
import requests
# プロジェクトの src を Python パスに追加
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(_PROJECT_ROOT, "src"))
from netflix_msl.constants import ( # noqa: E402
IOS_APPBOOT_ENDPOINT,
IOS_APPID,
IOS_APPKEYVERSION,
IOS_KDF_NONCE,
IOS_KDF_PSK,
IOS_KEY336_DEVICE_HEADER,
)
from netflix_msl.crypto import NetflixCrypto # noqa: E402
# ---------------------------------------------------------------------------
# 定数
# ---------------------------------------------------------------------------
TEST_ESN = "NFAPPL-02-IPHONE9=1-AD0455EF27D3A7B8F0872932FD9837874AF3E6F90157195BD22A8063FEB0B79E"
# デフォルトプロキシ (mitmproxy / Proxyman)
DEFAULT_PROXY = "http://192.168.0.51:9080"
# CBOR 数値キー定数
ENTITY_SCHEME = 30
ENTITY_AUTH_DATA = 35
KEYEX_SCHEME = 6
KEYEX_KEYDATA = 7
KEYEX_IDENTITY = 8
KEYEX_NONCE = 9
KEY_ENTITY_AUTH = 34
KEY_KEY_EXCHANGE = 33
KEY_MESSAGE_SIG = 16
# key 33.6 XOR nonce / nonce 変形マスク (実測値)
_N2_MASK = bytes([0xF4, 0x1B, 0x00, 0x00, 0x00, 0x00, 0x00])
_N3_MASK = bytes([0xF4, 0x1B, 0x00, 0x18, 0x1B, 0x00, 0x00])
_TAIL_MASK = bytes([0xF3, 0x1C, 0x07, 0x1F])
# ---------------------------------------------------------------------------
# Step 0: TFIT エミュレーション
# ---------------------------------------------------------------------------
def run_tfit_emulation(esn: str) -> tuple[bytes, bytes]:
"""tools/emulate_tfit.py を呼び出して MGK を導出する.
Returns:
(enc_key_0, sign_key_0) — 各 16B / 32B
"""
script = os.path.join(_PROJECT_ROOT, "tools", "emulate_tfit.py")
result = subprocess.run(
["uv", "run", script, esn],
capture_output=True,
text=True,
cwd=_PROJECT_ROOT,
)
if result.returncode != 0:
raise RuntimeError(f"TFIT エミュレーション失敗:\n{result.stderr}")
enc_key_0: bytes | None = None
sign_key_0: bytes | None = None
for line in result.stdout.splitlines():
if "MGK key" in line and "(16B)" in line:
enc_key_0 = bytes.fromhex(line.split(":")[-1].strip())
elif "MGK vector" in line and "(32B)" in line:
sign_key_0 = bytes.fromhex(line.split(":")[-1].strip())
if enc_key_0 is None or sign_key_0 is None:
raise RuntimeError(f"TFIT 出力から MGK を抽出できなかった:\n{result.stdout}")
return enc_key_0, sign_key_0
# ---------------------------------------------------------------------------
# Step 1: entity_auth_data の構築 (実測フォーマット)
# ---------------------------------------------------------------------------
def build_entity_auth_data(
esn: str,
apphmac: bytes,
devicetoken: bytes,
appid: str = IOS_APPID,
appkeyversion: int = IOS_APPKEYVERSION,
) -> dict:
"""実測フォーマットの entity_auth_data dict を構築する.
entity_auth_data CBOR 構造 (243 リクエストから確認済み):
{
35: {
"apphmac": bytes # raw bytes (初回は b"")
"appid": str
"appkeyversion": int
"devicetoken": bytes # raw bytes 216B (初回は b"")
3: str # 完全 ESN (整数キー 3)
},
30: "MGK_APPID"
}
"""
auth_data: dict = {
"apphmac": apphmac,
"appid": appid,
"appkeyversion": appkeyversion,
"devicetoken": devicetoken,
3: esn, # 整数キー
}
return {
ENTITY_AUTH_DATA: auth_data,
ENTITY_SCHEME: "MGK_APPID",
}
# ---------------------------------------------------------------------------
# Step 2: key_request_data の構築 (key 33)
# ---------------------------------------------------------------------------
def build_key336_scheme_data(
session_region: bytes,
nonce_7b: bytes,
s1: bytes,
s2: bytes,
s3: bytes,
k9_xor_nonce: bytes,
) -> bytes:
"""key 33.6 scheme_data 352B を XOR 暗号化して返す.
NetflixCrypto.build_key336_scheme_data() と同じロジック。
"""
n = nonce_7b
n2 = bytes(a ^ b for a, b in zip(n, _N2_MASK))
n3 = bytes(a ^ b for a, b in zip(n, _N3_MASK))
tail = bytes(a ^ b for a, b in zip(n[:4], _TAIL_MASK))
plaintext = (
IOS_KEY336_DEVICE_HEADER # [0:128]
+ session_region # [128:300]
+ n # [300:307]
+ s1 # [307:316]
+ n2 # [316:323]
+ s2 # [323:332]
+ n3 # [332:339]
+ s3 # [339:348]
+ tail # [348:352]
)
assert len(plaintext) == 352
return bytes(plaintext[i] ^ k9_xor_nonce[i % 16] for i in range(352))
def build_key_request_data(
session_region: bytes,
s1: bytes,
s2: bytes,
s3: bytes,
esn: str,
) -> tuple[bytes, bytes, bytes]:
"""key_request_data CBOR バイト列を構築する.
Returns:
(key_request_bytes, k9_xor_nonce, nonce_7b)
"""
k9_xor_nonce = os.urandom(16)
nonce_7b = os.urandom(7)
scheme_data = build_key336_scheme_data(
session_region=session_region,
nonce_7b=nonce_7b,
s1=s1,
s2=s2,
s3=s3,
k9_xor_nonce=k9_xor_nonce,
)
key_request = {
KEYEX_SCHEME: scheme_data,
KEYEX_KEYDATA: b"", # master_token = empty (新規セッション)
KEYEX_IDENTITY: esn, # identity = 完全 ESN (サフィックスなし)
KEYEX_NONCE: k9_xor_nonce,
}
return cbor2.dumps(key_request), k9_xor_nonce, nonce_7b
# ---------------------------------------------------------------------------
# Step 3: appboot メッセージを構築
# ---------------------------------------------------------------------------
def build_appboot_message(
entity_auth_data: dict,
key_request_bytes: bytes,
sign_key_0: bytes,
) -> bytes:
"""appboot CBOR MSL メッセージを構築する.
トップレベル構造:
{
34: bytes ← CBOR(entity_auth_data)
33: bytes ← key_request_data
16: bytes ← HMAC-SHA256(sign_key_0, key_request_data_bytes)
}
署名の対象は key_request_data_bytes のみ (実測で確認済み)。
"""
signature = hmac_mod.new(sign_key_0, key_request_bytes, hashlib.sha256).digest()
return cbor2.dumps(
{
KEY_ENTITY_AUTH: cbor2.dumps(entity_auth_data),
KEY_KEY_EXCHANGE: key_request_bytes,
KEY_MESSAGE_SIG: signature,
}
)
# ---------------------------------------------------------------------------
# Step 4: レスポンス解析
# ---------------------------------------------------------------------------
def parse_appboot_response(raw: bytes) -> dict:
"""appboot レスポンス CBOR または JSON MSL エラーを解析する.
サーバーは 2 種類のレスポンスを返す:
- 成功 (CBOR): {33: key_response_data_bytes, 16: signature, ...}
- エラー (JSON): {"signature": "...", "errordata": "<base64>"}
Returns:
{
"server_scheme_data": bytes | None,
"server_nonce": bytes | None,
"scheme_id": str | None,
"status_flag": bytes | None,
"raw": dict,
"error": dict | None, # MSL エラーが返った場合
}
"""
import json as _json
result: dict = {
"server_scheme_data": None,
"server_nonce": None,
"scheme_id": None,
"status_flag": None,
"raw": {},
"error": None,
}
# JSON MSL エラーレスポンスを先に試みる
if raw and raw[0] == ord("{"):
try:
envelope = _json.loads(raw)
result["raw"] = envelope
errordata_b64 = envelope.get("errordata", "")
if errordata_b64:
import base64 as _b64
pad = 4 - len(errordata_b64) % 4
padded = errordata_b64 + ("=" * pad if pad != 4 else "")
try:
error_dict = _json.loads(_b64.b64decode(padded))
result["error"] = error_dict
except Exception:
result["error"] = {"_raw": errordata_b64}
return result
except _json.JSONDecodeError:
pass
try:
top = cbor2.loads(raw)
except Exception as e:
raise ValueError(f"CBOR デコード失敗: {e}") from e
result: dict = {
"server_scheme_data": None,
"server_nonce": None,
"scheme_id": None,
"status_flag": None,
"raw": top,
}
krd_raw = top.get(KEY_KEY_EXCHANGE)
if krd_raw is None:
return result
try:
krd = cbor2.loads(krd_raw) if isinstance(krd_raw, bytes) else krd_raw
except Exception:
return result
if not isinstance(krd, dict):
return result
v6 = krd.get(KEYEX_SCHEME)
if isinstance(v6, bytes):
result["server_scheme_data"] = v6
v7 = krd.get(KEYEX_KEYDATA)
if isinstance(v7, bytes):
result["status_flag"] = v7
v8 = krd.get(KEYEX_IDENTITY)
if v8 is not None:
result["scheme_id"] = str(v8)
v9 = krd.get(KEYEX_NONCE)
if isinstance(v9, bytes):
result["server_nonce"] = v9
return result
# ---------------------------------------------------------------------------
# メインフロー
# ---------------------------------------------------------------------------
def run_e2e_test(
esn: str,
device_id_token: str | None,
devicetoken_bytes: bytes | None,
proxy_url: str | None,
timeout: int,
) -> None:
"""appboot E2E テストを実行する."""
sep = "=" * 60
print(sep)
print("iOS MSL appboot End-to-End テスト")
print(sep)
print(f"ESN: {esn}")
if device_id_token:
print(f"device_id_token: {device_id_token[:40]}... (継続セッション)")
else:
print("device_id_token: なし (初回リクエスト)")
print()
# ------------------------------------------------------------------
# Phase 0: TFIT エミュレーションで MGK を導出
# ------------------------------------------------------------------
print("[Phase 0] TFIT エミュレーション — MGK 導出中...")
try:
enc_key_0, sign_key_0 = run_tfit_emulation(esn)
print(f" enc_key_0: {enc_key_0.hex()}")
print(f" sign_key_0: {sign_key_0.hex()}")
except RuntimeError as e:
print(f" ERROR: {e}")
print(" TFIT エミュレーション失敗。処理を中断します。")
sys.exit(1)
# ------------------------------------------------------------------
# Phase 3: KDF で sign_key_1 を導出 (appboot 署名用)
# ------------------------------------------------------------------
print()
print("[Phase 3] KDF — sign_key_0 確認 (appboot 署名に sign_key_0 を使用)...")
enc_key_1, sign_key_1 = NetflixCrypto.kdf_renew(
IOS_KDF_PSK, enc_key_0, sign_key_0, IOS_KDF_NONCE
)
print(f" enc_key_1: {enc_key_1.hex()}")
print(f" sign_key_1: {sign_key_1.hex()}")
print()
print(" NOTE: appboot 署名は sign_key_0 を使用 (実測で確認)")
# ------------------------------------------------------------------
# entity_auth_data フィールドを準備
# ------------------------------------------------------------------
if device_id_token:
# 継続セッション: deviceIdToken から apphmac と devicetoken を設定
apphmac = device_id_token.encode("utf-8")
if devicetoken_bytes is not None:
devicetoken = devicetoken_bytes
else:
try:
# Base64 パディング補完してデコード
pad = 4 - len(device_id_token) % 4
padded = device_id_token + ("=" * pad if pad != 4 else "")
devicetoken = base64.b64decode(padded)
except Exception as e:
print(f" WARNING: device_id_token Base64 デコード失敗: {e}")
devicetoken = b""
else:
# 初回リクエスト: 両フィールドを空バイトに設定
apphmac = b""
devicetoken = b""
print()
print("[entity_auth] フィールド準備:")
print(
f" apphmac: {apphmac[:32].hex() if apphmac else '(empty)'} ({len(apphmac)}B)"
)
print(
f" devicetoken: {devicetoken[:16].hex() if devicetoken else '(empty)'} ({len(devicetoken)}B)"
)
# ------------------------------------------------------------------
# Phase 1a: DH 鍵ペアを生成
# ------------------------------------------------------------------
print()
print("[Phase 1a] DH 鍵ペア生成中...")
dh_priv_key, dh_pub_key = NetflixCrypto.generate_dh_keypair()
print(f" DH pub_key: {dh_pub_key[:16].hex()}... ({len(dh_pub_key)}B)")
# ------------------------------------------------------------------
# session_region を DH 公開鍵からゼロパディングで仮構築
# ------------------------------------------------------------------
# TODO: 本来は TFIT-WB-AES (tools/emulate_tfit.py の session_region 導出) が必要。
# 現在は 172B のゼロ埋めプレースホルダーを使用。
# このため key 33.6 の内容は正しくなく、サーバーは鍵交換を拒否する。
# Phase 2 以降の DH 鍵合意は成立しない。
# 正式フローでは emulate_tfit.py で DH 公開鍵を TFIT-WB-AES 暗号化し
# session_region (172B) に格納する。
session_region_placeholder = b"\x00" * 172
# セパレータは実測キャプチャから取得した既知の値を使用
# (セッション固有値のため、実際の接続では Frida キャプチャが必要)
s1 = b"\x00" * 9
s2 = b"\x00" * 9
s3 = b"\x00" * 9
# ------------------------------------------------------------------
# Phase 1b: entity_auth_data と key_request_data を構築
# ------------------------------------------------------------------
print()
print("[Phase 1b] CBOR メッセージを構築中...")
entity_auth_data = build_entity_auth_data(
esn=esn,
apphmac=apphmac,
devicetoken=devicetoken,
)
key_request_bytes, k9_xor_nonce, nonce_7b = build_key_request_data(
session_region=session_region_placeholder,
s1=s1,
s2=s2,
s3=s3,
esn=esn,
)
cbor_message = build_appboot_message(
entity_auth_data=entity_auth_data,
key_request_bytes=key_request_bytes,
sign_key_0=sign_key_0,
)
print(f" CBOR message size: {len(cbor_message)} bytes")
print(f" k9_xor_nonce: {k9_xor_nonce.hex()}")
print(f" nonce_7b: {nonce_7b.hex()}")
# 構築したメッセージの構造を検証
decoded_check = cbor2.loads(cbor_message)
print(f" top-level keys: {sorted(decoded_check.keys())}")
ead_check = cbor2.loads(decoded_check[KEY_ENTITY_AUTH])
print(f" entity_auth scheme: {ead_check[ENTITY_SCHEME]}")
krd_check = cbor2.loads(decoded_check[KEY_KEY_EXCHANGE])
print(f" key_request identity: {krd_check[KEYEX_IDENTITY]}")
# ------------------------------------------------------------------
# Phase 1c: POST appboot.netflix.com
# ------------------------------------------------------------------
esn_prefix = esn[: esn.rfind("-") + 1]
url = IOS_APPBOOT_ENDPOINT + esn_prefix
params = {"keyVersion": str(IOS_APPKEYVERSION)}
print()
print(f"[Phase 1c] HTTP POST — {url}")
if proxy_url:
print(f" proxy: {proxy_url}")
else:
print(" proxy: なし (直接接続)")
proxies = {"https": proxy_url, "http": proxy_url} if proxy_url else None
session = requests.Session()
session.headers.update(
{
"User-Agent": "Netflix/24 CFNetwork/1335.0.3.4 Darwin/21.6.0",
"Content-Type": "application/x-www-form-urlencoded",
"X-Netflix.APIAction": "appboot",
"X-Netflix.client.ftl.esn": esn,
"X-Netflix.Request.Attempt": "1",
"X-Netflix.Request.Client.Context": '{"appState":"foreground"}',
}
)
try:
resp = session.post(
url,
params=params,
data=cbor_message,
proxies=proxies,
verify=False, # mitmproxy では証明書検証をスキップ
timeout=timeout,
)
except requests.exceptions.ConnectionError as e:
print(f" CONNECTION ERROR: {e}")
print()
print("接続失敗。サーバーに到達できませんでした。")
print("原因として以下が考えられます:")
print(" - プロキシ (mitmproxy) が起動していない")
print(" - ネットワーク接続がない")
print(" - appboot.netflix.com がブロックされている")
_print_request_summary(cbor_message, esn, apphmac, devicetoken)
return
except requests.exceptions.Timeout:
print(f" TIMEOUT (>{timeout}s)")
_print_request_summary(cbor_message, esn, apphmac, devicetoken)
return
except Exception as e:
print(f" HTTP ERROR: {type(e).__name__}: {e}")
_print_request_summary(cbor_message, esn, apphmac, devicetoken)
return
print(f" HTTP {resp.status_code} ({len(resp.content)} bytes)")
print(f" Content-Type: {resp.headers.get('content-type', 'N/A')}")
# deviceIdToken を抽出
new_device_id_token = resp.headers.get("x-netflix-deviceidtoken", "")
nfstatus = resp.headers.get("X-Netflix.nfstatus", "")
if nfstatus:
print(f" X-Netflix.nfstatus: {nfstatus}")
print()
if resp.status_code != 200:
print(f"[RESULT] HTTP {resp.status_code} — appboot 失敗")
print(f" Response body: {resp.text[:500]}")
_print_request_summary(cbor_message, esn, apphmac, devicetoken)
return
# ------------------------------------------------------------------
# Phase 1d: レスポンス解析
# ------------------------------------------------------------------
print("[Phase 1d] レスポンスを解析中...")
if new_device_id_token:
print(f" x-netflix-deviceidtoken: {new_device_id_token[:60]}...")
try:
pad = 4 - len(new_device_id_token) % 4
padded = new_device_id_token + ("=" * pad if pad != 4 else "")
decoded_token = base64.b64decode(padded)
print(
f" decoded: {len(decoded_token)} bytes = {decoded_token[:16].hex()}..."
)
except Exception:
pass
else:
print(" x-netflix-deviceidtoken: なし")
nfvdid = resp.cookies.get("nfvdid", "")
if nfvdid:
print(f" nfvdid cookie: {nfvdid[:40]}...")
try:
parsed = parse_appboot_response(resp.content)
except ValueError as e:
print(f" レスポンス解析エラー: {e}")
print(f" raw (hex): {resp.content[:80].hex()}")
return
# MSL エラーレスポンスの場合は表示して終了
msl_error = parsed.get("error")
if msl_error is not None:
print(f" MSL エラーレスポンス: {msl_error}")
print()
print(sep)
print("[RESULT] appboot MSL エラー返却")
print(sep)
print(
f"STATUS: MSL エラー (errorcode={msl_error.get('errorcode', '?')},"
f" internalcode={msl_error.get('internalcode', '?')})"
)
print()
print("原因: entity_auth_data または key_request_data が不正です。")
print(" 正しい session_region (TFIT-WB-AES 暗号化 DH 公開鍵) が必要です。")
_print_request_summary(cbor_message, esn, apphmac, devicetoken)
return
server_scheme_data = parsed["server_scheme_data"]
server_nonce = parsed["server_nonce"]
scheme_id = parsed["scheme_id"]
print(f" scheme_id: {scheme_id}")
if server_scheme_data:
print(
f" server_scheme_data: {server_scheme_data[:16].hex()}... ({len(server_scheme_data)}B)"
)
else:
print(" server_scheme_data: なし")
if server_nonce:
print(f" server_nonce: {server_nonce.hex()} ({len(server_nonce)}B)")
else:
print(" server_nonce: なし")
# ------------------------------------------------------------------
# 結果サマリー
# ------------------------------------------------------------------
print()
print(sep)
print("[RESULT] appboot フロー結果")
print(sep)
success = server_scheme_data is not None and server_nonce is not None
if success:
print("STATUS: サーバーから key_response_data を受信 (鍵交換データあり)")
print()
print("取得値:")
print(f" server_scheme_data: {len(server_scheme_data)}B")
print(f" server_nonce: {server_nonce.hex()}")
print(f" scheme_id: {scheme_id}")
if new_device_id_token:
print(f" device_id_token: {new_device_id_token[:60]}...")
print()
print("次回リクエスト用コマンド:")
print(
f" uv run tools/test_appboot_e2e.py --device-id-token '{new_device_id_token}'"
)
print()
print("NOTE: session_region が正しい TFIT 暗号化 DH 公開鍵でないため、")
print(" サーバー側の DH 計算は失敗している可能性があります。")
print(
" Phase 2 (DH 鍵合意) を完了するには emulate_tfit.py の session_region 導出が必要。"
)
else:
print("STATUS: key_response_data が不完全 — 鍵交換未完了")
raw_top = parsed.get("raw", {})
print(
f" raw top-level keys: {sorted(raw_top.keys()) if isinstance(raw_top, dict) else 'N/A'}"
)
_print_request_summary(cbor_message, esn, apphmac, devicetoken)
def _print_request_summary(
cbor_message: bytes,
esn: str,
apphmac: bytes,
devicetoken: bytes,
) -> None:
"""送信したリクエストの概要を表示する."""
print()
print("--- 送信リクエスト概要 ---")
print(f" CBOR size: {len(cbor_message)} bytes")
print(f" ESN: {esn}")
print(
f" apphmac: {apphmac[:8].hex() if apphmac else '(empty)'} ({len(apphmac)}B)"
)
print(
f" devicetoken: {devicetoken[:8].hex() if devicetoken else '(empty)'} ({len(devicetoken)}B)"
)
# ---------------------------------------------------------------------------
# エントリーポイント
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="iOS MSL appboot End-to-End テスト",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--esn",
default=TEST_ESN,
help=f"PRV ESN (default: {TEST_ESN[:40]}...)",
)
parser.add_argument(
"--device-id-token",
default=None,
help="継続セッション用 device_id_token (x-netflix-deviceidtoken ヘッダー値, Base64)",
)
parser.add_argument(
"--devicetoken-hex",
default=None,
help="devicetoken の生バイト列 (hex)。--device-id-token と組み合わせて使用。",
)
parser.add_argument(
"--proxy",
default=DEFAULT_PROXY,
help=f"プロキシ URL (default: {DEFAULT_PROXY})",
)
parser.add_argument(
"--no-proxy",
action="store_true",
help="プロキシを使用しない (直接接続)",
)
parser.add_argument(
"--timeout",
type=int,
default=30,
help="HTTP タイムアウト秒数 (default: 30)",
)
args = parser.parse_args()
proxy_url = None if args.no_proxy else args.proxy
devicetoken_bytes: bytes | None = None
if args.devicetoken_hex:
devicetoken_bytes = bytes.fromhex(args.devicetoken_hex)
# urllib3 の SSL 警告を抑制
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
run_e2e_test(
esn=args.esn,
device_id_token=args.device_id_token,
devicetoken_bytes=devicetoken_bytes,
proxy_url=proxy_url,
timeout=args.timeout,
)
if __name__ == "__main__":
main()