diff --git a/src/netflix_msl/__init__.py b/src/netflix_msl/__init__.py index e744b4a..4d715c6 100644 --- a/src/netflix_msl/__init__.py +++ b/src/netflix_msl/__init__.py @@ -14,12 +14,17 @@ from netflix_msl.constants import ( ENetflixProfile, ENetflixVideoCodec, ) -from netflix_msl.crypto import NetflixCrypto +from netflix_msl.crypto import NetflixCrypto, SessionKeys from netflix_msl.client import NetflixMSL +from netflix_msl.ios_client import iOSMslClient, iOSAppbootParams, iOSSessionState __all__ = [ "NetflixMSL", "NetflixCrypto", + "SessionKeys", + "iOSMslClient", + "iOSAppbootParams", + "iOSSessionState", "ENDPOINTS", "ENetflixVideoCodec", "ENetflixProfile", diff --git a/src/netflix_msl/cbor_decoder.py b/src/netflix_msl/cbor_decoder.py index f6daf89..f7acf7a 100644 --- a/src/netflix_msl/cbor_decoder.py +++ b/src/netflix_msl/cbor_decoder.py @@ -281,6 +281,89 @@ class CborMslDecoder: "raw_message": msg["raw"], } + def parse_appboot_response(self, raw: bytes) -> dict: + """appboot レスポンス CBOR を解析し、鍵交換データを抽出する. + + appboot レスポンス構造 (msl_cbor_key_exchange_analysis.md §1.2): + { + 33: bytes ← key_response_data + 16: bytes ← message signature + 32: dict ← header (capabilities) + } + + key_response_data (key 33) の sub-key: + 6: bytes(96) ← サーバー DH レスポンス (推定: IV(16B) + CT(48B) + HMAC(32B)) + 7: bytes(1) ← ステータスフラグ (0x00) + 8: str ← スキーム ID ("3" または "5") + 9: bytes(16) ← サーバー nonce (KDF 入力) + + Args: + raw: appboot レスポンスの生バイト列 (CBOR 形式) + + Returns: + { + "key_response_data": dict | None, # CBOR デコードされた key_response_data + "server_scheme_data": bytes | None, # key 33.6 (96B) — サーバー DH レスポンス + "server_nonce": bytes | None, # key 33.9 (16B) — サーバー nonce + "scheme_id": str | None, # key 33.8 — スキーム ID + "status_flag": bytes | None, # key 33.7 — ステータスフラグ + "header": dict | None, # CBOR デコードされたヘッダー + "signature": bytes | None, # メッセージ署名 (32B) + "raw_message": list[dict], # CBOR 生データ + } + + Raises: + DecodeError: CBOR デコードまたはパースに失敗した場合 + """ + msg = self.decode_message(raw) + + key_exchange_raw = msg.get("key_exchange") + key_response_data: dict | None = None + server_scheme_data: bytes | None = None + server_nonce: bytes | None = None + scheme_id: str | None = None + status_flag: bytes | None = None + + if key_exchange_raw is not None: + # key_exchange はバイト列の場合 CBOR デコード + if isinstance(key_exchange_raw, bytes): + krd = self._try_decode_cbor(key_exchange_raw) + else: + krd = key_exchange_raw + + if isinstance(krd, dict): + key_response_data = krd + # sub-key 6: サーバー DH レスポンス (96B) + v6 = krd.get(KEYEX_SCHEME) + if isinstance(v6, bytes): + server_scheme_data = v6 + + # sub-key 7: ステータスフラグ + v7 = krd.get(KEYEX_KEYDATA) + if isinstance(v7, bytes): + status_flag = v7 + + # sub-key 8: スキーム ID ("3" / "5") + v8 = krd.get(KEYEX_IDENTITY) + if v8 is not None: + scheme_id = str(v8) + + # sub-key 9: サーバー nonce (16B) + v9 = krd.get(KEYEX_NONCE) + if isinstance(v9, bytes): + server_nonce = v9 + + return { + "key_response_data": key_response_data, + "server_scheme_data": server_scheme_data, + "server_nonce": server_nonce, + "scheme_id": scheme_id, + "status_flag": status_flag, + "header": msg.get("header"), + "signature": msg.get("signature"), + "raw_message": msg.get("raw", []), + } + # ------------------------------------------------------------------ # 内部ヘルパー # ------------------------------------------------------------------ diff --git a/src/netflix_msl/cbor_encoder.py b/src/netflix_msl/cbor_encoder.py index 5f0e04f..2b88b5c 100644 --- a/src/netflix_msl/cbor_encoder.py +++ b/src/netflix_msl/cbor_encoder.py @@ -371,6 +371,132 @@ class CborMslEncoder: signature=signature, ) + def build_appboot_request( + self, + esn: str, + dh_pub_key: bytes, + appid: str, + devicetoken: str, + apphmac: str, + device_key_data: bytes, + session_region: bytes, + s1: bytes, + s2: bytes, + s3: bytes, + renewable: bytes, + capabilities: dict, + appkeyversion: int = 1, + esn_prefix: str = "", + scheme_suffix: str = "_3", + ) -> tuple[bytes, bytes, bytes]: + """iOS appboot CBOR MSL リクエストを構築する. + + appboot リクエストの全体構造 (msl_cbor_key_exchange_analysis.md §1.1): + { + 34: CBOR(entity_auth_data) ← FAIRPLAY_MGK_APPID + 33: CBOR(key_request_data) ← Scheme 3, scheme_data 352B XOR 暗号化 + 32: CBOR(header) ← capabilities + 16: HMAC-SHA256(32 bytes) ← メッセージ署名 + } + + key 33 の sub-key: + 6: scheme_data (464B) — build_key336_scheme_data で生成した 352B を含む XOR 暗号化データ + 7: master_token (空 bytes) — 新規セッション + 8: identity (ESN + scheme_suffix) + 9: k9_xor_nonce (16B) + + Args: + esn: PRV ESN 全体 + dh_pub_key: TFIT 暗号化済み DH 公開鍵を含む session_region の元となる + クライアント DH 公開鍵 (128 bytes)。 + TODO: TFIT エミュレーションが未実装のため、session_region を + 直接渡す方式を使用する。 + appid: アプリ識別子 UUID + devicetoken: デバイストークン (hex string) + TODO: Frida/Tweak キャプチャで取得が必要 + apphmac: アプリ認証 HMAC-SHA256 (hex string) + TODO: Frida/Tweak キャプチャで取得が必要 + device_key_data: デバイス固有鍵データ (~6,576 bytes) + TODO: Frida/Tweak キャプチャで取得が必要 + session_region: 172B セッション領域 (key 33.6 平文の [128:300])。 + TFIT エミュレーションまたは Frida キャプチャで取得。 + TODO: TFIT エミュレーション (tools/emulate_tfit.py) 実装後に自動化 + s1: 9B セッション固定セパレータ + TODO: Frida キャプチャで取得が必要 + s2: 9B セッション固定セパレータ + TODO: Frida キャプチャで取得が必要 + s3: 9B セパレータ (per-request counter) + TODO: Frida キャプチャで取得が必要 + renewable: capabilities key 16 の固定バイト列 (44 bytes) + TODO: 既知の固定値をキャプチャから取得 + capabilities: capabilities dict (key 15 の値) + appkeyversion: 鍵バージョン (default 1) + esn_prefix: ESN プレフィックス。空の場合は esn から自動抽出。 + scheme_suffix: identity サフィックス (default "_3") + + Returns: + (cbor_message, k9_xor_nonce, nonce_7b): + cbor_message : CBOR エンコードされた appboot リクエスト全体 + k9_xor_nonce : key 33.9 として使用した 16B XOR nonce + nonce_7b : key 33.6 内の 7B per-request nonce + + Raises: + EncodeError: session_region の長さが不正な場合 + """ + import os + + from netflix_msl.crypto import NetflixCrypto + + # --- 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, k9_xor_nonce = NetflixCrypto.build_key336_scheme_data( + session_region=session_region, + nonce_7b=nonce_7b, + s1=s1, + s2=s2, + s3=s3, + k9_xor_nonce=k9_xor_nonce, + ) + + # --- entity_auth_data を構築 --- + entity_auth_data = self.build_entity_auth_data( + esn=esn, + appid=appid, + devicetoken=devicetoken, + apphmac=apphmac, + device_key_data=device_key_data, + appkeyversion=appkeyversion, + esn_prefix=esn_prefix, + ) + + # --- header を構築 --- + header_bytes = self.build_header( + renewable=renewable, + capabilities=capabilities, + ) + + # --- key_request_data を構築 --- + # identity = ESN + scheme suffix (e.g. "NFAPPL-02-..._3") + identity = esn + scheme_suffix + key_request_bytes = self.build_key_request_data( + scheme_data=scheme_data_enc, + master_token=b"", + identity=identity, + nonce=k9_xor_nonce, + ) + + # --- appboot メッセージを構築 (署名なし) --- + cbor_message = self.build_appboot_message( + entity_auth_data=entity_auth_data, + header_bytes=header_bytes, + key_request_bytes=key_request_bytes, + ) + + return cbor_message, k9_xor_nonce, nonce_7b + # ------------------------------------------------------------------ # ユーティリティ # ------------------------------------------------------------------ diff --git a/src/netflix_msl/constants.py b/src/netflix_msl/constants.py index 851bda3..079b7f6 100644 --- a/src/netflix_msl/constants.py +++ b/src/netflix_msl/constants.py @@ -50,6 +50,20 @@ RSA_KEYPAIR_ID = "rsaKeypairId" IOS_KDF_PSK = bytes.fromhex("027617984f6227539a630b897c017d69") IOS_KDF_NONCE = bytes.fromhex("809f82a7addf548d3ea9dd067ff9bb91") +# iOS DH パラメータ (1024-bit, g=5, p は Netflix 固有値) +# 出典: raws/msl_keys.json "dh_p" / "dh_g" +IOS_DH_P = int( + "9694e9d8d93a5ac74c509b4bbce85e92132cd19cce477d1a7e47d527d9ec291515f0b8b3e1" + "eaed5006e1b1b91ea25b91a01b10e2e834b8d660b2e321ad644ce1a83b328d9014ee7e16f1" + "e44ffe89579ac3ee47d668b6b76687c2fe90a35b5e6028fd04efea882373ecf60ba2f637e4" + "cdaa1b6089d6c0b561a8e520e796de27df", + 16, +) +IOS_DH_G = 5 + +# appboot エンドポイント +IOS_APPBOOT_ENDPOINT = "https://appboot.netflix.com/appboot/" + # key 33.6 scheme_data の固定デバイスヘッダー (128B) # 180 個の 352B appboot サンプルのうち 165 個 (標準 iPhone) で共通の定数。 # plaintext[0:128] の値 (XOR 復号後)。 diff --git a/src/netflix_msl/crypto.py b/src/netflix_msl/crypto.py index 0eff797..16d1c61 100644 --- a/src/netflix_msl/crypto.py +++ b/src/netflix_msl/crypto.py @@ -13,12 +13,31 @@ import base64 import hashlib import hmac as hmac_mod import os +from dataclasses import dataclass from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.hazmat.primitives.asymmetric import dh, padding, rsa from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -from netflix_msl.constants import IOS_KEY336_DEVICE_HEADER, RSA_KEYPAIR_ID +from netflix_msl.constants import ( + IOS_DH_G, + IOS_DH_P, + IOS_KDF_NONCE, + IOS_KDF_PSK, + IOS_KEY336_DEVICE_HEADER, + RSA_KEYPAIR_ID, +) + + +@dataclass +class SessionKeys: + """MSL セッション鍵セット.""" + + enc_key: bytes # AES-128 暗号化鍵 (16 bytes) + sign_key: bytes # HMAC-SHA256 署名鍵 (32 bytes) + bootstrap_key: bytes # ペイロード全体署名鍵 = Phase 2 sign_key (32 bytes) + enc_key_1: bytes # Phase 3 KDF 更新済み暗号化鍵 (16 bytes) + sign_key_1: bytes # Phase 3 KDF 更新済み署名鍵 (32 bytes) class NetflixCrypto: @@ -215,6 +234,135 @@ class NetflixCrypto: return bool(self.encryption_key and self.sign_key) + # ---- DH 鍵交換 (1024-bit, g=5, Netflix 固有 p) ---- + + @staticmethod + def generate_dh_keypair( + p: bytes | None = None, + g: int | None = None, + ) -> tuple[bytes, bytes]: + """DH 鍵ペアを生成する. + + Args: + p: DH 素数 (big-endian bytes)。None の場合は IOS_DH_P 定数を使用。 + g: DH 生成元。None の場合は IOS_DH_G 定数を使用。 + + Returns: + (private_key_bytes, public_key_bytes) — 各 128 bytes (1024-bit) + """ + p_int = int.from_bytes(p, "big") if p is not None else IOS_DH_P + g_int = g if g is not None else IOS_DH_G + + params = dh.DHParameterNumbers(p_int, g_int) + parameters = params.parameters() + private_key = parameters.generate_private_key() + public_key = private_key.public_key() + + pn = private_key.private_numbers() + pub_n = public_key.public_numbers() + + # 1024-bit = 128 bytes に固定長でシリアライズ (big-endian) + key_len = (p_int.bit_length() + 7) // 8 + priv_bytes = pn.x.to_bytes(key_len, "big") + pub_bytes = pub_n.y.to_bytes(key_len, "big") + return priv_bytes, pub_bytes + + @staticmethod + def compute_dh_shared_secret( + peer_public: bytes, + private_key: bytes, + p: bytes | None = None, + g: int | None = None, + ) -> bytes: + """DH 共有秘密を計算する. + + Args: + peer_public: 相手の DH 公開鍵 (big-endian bytes, 128 bytes) + private_key: 自分の DH 秘密鍵 (big-endian bytes, 128 bytes) + p: DH 素数 (big-endian bytes)。None の場合は IOS_DH_P 定数を使用。 + g: DH 生成元。None の場合は IOS_DH_G 定数を使用。 + + Returns: + DH 共有秘密 (big-endian bytes, 128 bytes) + """ + p_int = int.from_bytes(p, "big") if p is not None else IOS_DH_P + # g is not needed for DH shared secret computation (peer_pub ^ priv mod p) + _ = g + + peer_y = int.from_bytes(peer_public, "big") + priv_x = int.from_bytes(private_key, "big") + + # shared = peer_y ^ priv_x mod p + shared_int = pow(peer_y, priv_x, p_int) + key_len = (p_int.bit_length() + 7) // 8 + return shared_int.to_bytes(key_len, "big") + + # ---- 全鍵導出チェーン (Phase 0 MGK → Phase 3 KDF → Phase 2 DH) ---- + + @staticmethod + def derive_full_key_chain( + enc_key_0: bytes, + sign_key_0: bytes, + dh_shared_secret: bytes, + psk: bytes | None = None, + nonce: bytes | None = None, + ) -> "SessionKeys": + """Phase 3 KDF → Phase 2 DH → SessionKeys の全導出チェーンを実行する. + + 実行順序 (msl_key_relationship.md §2): + Phase 3: kdf_renew(PSK, enc_key_0, sign_key_0, nonce) → enc_key_1, sign_key_1, session_bind + 48B Key: SHA384(session_bind[:16]) + Phase 2: derive_initial_session_keys(48B_KEY, dh_shared_secret) + → new_enc_key (enc_key), bootstrap_key (sign_key) + + Args: + enc_key_0: Phase 0 MGK 暗号化鍵 (16 bytes)。 + Frida/Tweak キャプチャまたは TFIT エミュレーションで取得。 + sign_key_0: Phase 0 MGK 署名鍵 (32 bytes)。 + dh_shared_secret: DH_compute_key() の出力 (128 bytes)。 + Frida/Tweak キャプチャで取得。 + psk: Pre-Shared Key (16 bytes)。None の場合は IOS_KDF_PSK を使用。 + nonce: KDF nonce (16 bytes)。None の場合は IOS_KDF_NONCE を使用。 + + Returns: + SessionKeys: enc_key, sign_key, bootstrap_key, enc_key_1, sign_key_1 + + Raises: + ValueError: 入力長が不正な場合 + """ + if len(enc_key_0) != 16: + raise ValueError(f"enc_key_0 must be 16 bytes, got {len(enc_key_0)}") + if len(sign_key_0) != 32: + raise ValueError(f"sign_key_0 must be 32 bytes, got {len(sign_key_0)}") + if len(dh_shared_secret) != 128: + raise ValueError( + f"dh_shared_secret must be 128 bytes, got {len(dh_shared_secret)}" + ) + + _psk = psk if psk is not None else IOS_KDF_PSK + _nonce = nonce if nonce is not None else IOS_KDF_NONCE + + # Phase 3: KDF Key Renewal (HMAC-SHA256 chain) + enc_key_1, sign_key_1 = NetflixCrypto.kdf_renew( + _psk, enc_key_0, sign_key_0, _nonce + ) + + # 48B Key: SHA384(session_bind[:16]) + key_48b = NetflixCrypto.derive_hmac384_key(_psk, enc_key_0, sign_key_0, _nonce) + + # Phase 2: HMAC-SHA384(48B_KEY, 0x00 || dh_shared_secret) + enc_key, bootstrap_key = NetflixCrypto.derive_initial_session_keys( + key_48b, dh_shared_secret + ) + + return SessionKeys( + enc_key=enc_key, + sign_key=enc_key_1, # 初期 MSL 暗号化鍵 = enc_key_1 (Phase 4 で使用) + bootstrap_key=bootstrap_key, # ペイロード全体署名鍵 (Phase 5) + enc_key_1=enc_key_1, + sign_key_1=sign_key_1, + ) + # ---- Scheme 5 (DH) KDF: セッション鍵更新 ---- @staticmethod diff --git a/src/netflix_msl/ios_client.py b/src/netflix_msl/ios_client.py new file mode 100644 index 0000000..9a19c86 --- /dev/null +++ b/src/netflix_msl/ios_client.py @@ -0,0 +1,486 @@ +"""iOSMslClient — iOS Netflix MSL appboot-to-session オーケストレーター + +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 キャプチャで取得) + + 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 + レスポンス解析 → server_scheme_data (96B), server_nonce (16B) + + Phase 2: DH_compute_key(server_pub_key, client_priv_key) + → HMAC-SHA384(48B_KEY, 0x00 || shared_secret) → enc_key, bootstrap_key + + Phase 4: (ログイン後) key_response_data から AES-128-CBC 復号 → enc_key_2, sign_key_2 + + Phase 5: MSL 通信 (AES-128-CBC + HMAC-SHA256) + +## 制約 + +- key 33.6 の TFIT エンコード部 (DH 公開鍵 128B → session_region 172B) は + tools/emulate_tfit.py による Unicorn エミュレーションが必要。 + 現在は 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: Frida/Tweak `AppbootKeyExtract` でキャプチャ + - 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 + +from dataclasses import dataclass, field + +import requests + +from netflix_msl.cbor_decoder import CborMslDecoder +from netflix_msl.cbor_encoder import CborMslEncoder +from netflix_msl.constants import IOS_APPBOOT_ENDPOINT +from netflix_msl.crypto import NetflixCrypto, SessionKeys + + +@dataclass +class iOSAppbootParams: + """appboot リクエストに必要なデバイス固有パラメータ. + + 全フィールドは Frida/Tweak キャプチャまたは TFIT エミュレーションで取得する。 + """ + + # Phase 0 MGK (TFIT エミュレーションで導出可能) + enc_key_0: bytes + """Phase 0 MGK 暗号化鍵 (16 bytes). TFIT エミュレーションで導出可能.""" + + 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). + TODO: Frida/Tweak AppbootKeyExtract でキャプチャ.""" + + device_key_data: bytes + """デバイス固有鍵データ (~6,576 bytes). + TODO: Frida/Tweak AppbootKeyExtract でキャプチャ.""" + + # 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 による自動導出が未実装.""" + + # key 33.6 separators + s1: bytes + """key 33.6 の 9B セッション固定セパレータ (pt[307:316]). + TODO: Frida キャプチャで取得が必要.""" + + s2: bytes + """key 33.6 の 9B セッション固定セパレータ (pt[323:332]). + 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: キャプチャから取得した値を使用すること.""" + + # オプション + appkeyversion: int = 1 + esn_prefix: str = "" + scheme_suffix: str = "_3" + + +@dataclass +class iOSSessionState: + """appboot 完了後のセッション状態.""" + + session_keys: SessionKeys + """Phase 2/3 で導出されたセッション鍵セット.""" + + server_nonce: bytes + """appboot レスポンスの key 33.9 (16B).""" + + server_scheme_data: bytes + """appboot レスポンスの key 33.6 (96B) — サーバー DH レスポンス.""" + + scheme_id: str + """スキーム ID ("3" または "5").""" + + nfvdid_cookie: str = "" + """Set-Cookie: nfvdid から取得したデバイス ID Cookie.""" + + device_id_token: str = "" + """x-netflix-deviceidtoken レスポンスヘッダーから取得したデバイス ID トークン.""" + + dh_priv_key: bytes = field(default_factory=bytes) + """クライアント DH 秘密鍵 (128 bytes). Phase 2 で使用後も保持する.""" + + dh_pub_key: bytes = field(default_factory=bytes) + """クライアント DH 公開鍵 (128 bytes).""" + + +class iOSMslClient: + """iOS Netflix MSL appboot から暗号化 MSL リクエストまでのオーケストレーター. + + 使用例 (Frida/Tweak でキャプチャした値を使用): + + params = iOSAppbootParams( + enc_key_0=bytes.fromhex("0817065e..."), + sign_key_0=bytes.fromhex("91f752f7..."), + appid="a2becfec-b286-535c-b884-903a384caee6", + devicetoken="0608a1b7...", # TODO: Frida キャプチャ + apphmac="a18bf28f...", # TODO: Frida キャプチャ + 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"...", ...}, + ) + + client = iOSMslClient( + esn="NFAPPL-02-IPHONE9=1-AD0455...", + appboot_params=params, + ) + + session = client.perform_appboot() + # session.session_keys.enc_key_1 で以降の MSL リクエストを暗号化 + """ + + def __init__( + self, + esn: str, + appboot_params: iOSAppbootParams, + timeout: int = 30, + ) -> None: + """クライアントを初期化する. + + Args: + esn: PRV ESN (e.g. "NFAPPL-02-IPHONE9=1-AD0455...") + appboot_params: デバイス固有パラメータ (Frida キャプチャ値) + timeout: HTTP タイムアウト秒数 (default 30) + """ + self.esn = esn + self.params = appboot_params + self.timeout = timeout + + self.crypto = NetflixCrypto() + self.session_state: iOSSessionState | None = None + + # HTTP セッション + self._http = requests.Session() + self._http.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"}', + } + ) + + # ------------------------------------------------------------------ + # パブリック API + # ------------------------------------------------------------------ + + def perform_appboot(self) -> iOSSessionState: + """appboot フローを実行してセッション状態を返す. + + 手順: + 1. DH 鍵ペアを生成 + 2. appboot CBOR リクエストを構築 + 3. POST appboot.netflix.com/{ESN_PREFIX}?keyVersion=1 + 4. レスポンスを解析して server_scheme_data と server_nonce を抽出 + 5. DH 共有秘密を計算 + 6. Phase 2/3 KDF で SessionKeys を導出 + + Returns: + iOSSessionState: セッション鍵・Cookie・deviceIdToken を含む状態 + + Raises: + RuntimeError: appboot リクエストが失敗した場合 + ValueError: レスポンスの解析に失敗した場合 + """ + # --- Step 1: DH 鍵ペアを生成 --- + print("[appboot] Step 1: 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)") + + # --- 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, + ) + print(f" Request size: {len(cbor_message)} bytes") + + # --- Step 3: POST appboot --- + esn_prefix = self._extract_esn_prefix(self.esn) + url = IOS_APPBOOT_ENDPOINT + esn_prefix + params = {"keyVersion": str(self.params.appkeyversion)} + + print(f"[appboot] Step 3: POST {url}") + resp = self._http.post( + url, + params=params, + data=cbor_message, + timeout=self.timeout, + ) + print(f" HTTP {resp.status_code} ({len(resp.content)} bytes)") + + if resp.status_code != 200: + raise RuntimeError( + f"appboot failed: HTTP {resp.status_code} — {resp.text[:500]}" + ) + + # --- Step 4: レスポンスを解析 --- + print("[appboot] Step 4: レスポンスを解析中...") + decoder = CborMslDecoder(self.crypto) + parsed = decoder.parse_appboot_response(resp.content) + + server_scheme_data = parsed.get("server_scheme_data") + server_nonce = parsed.get("server_nonce") + scheme_id = parsed.get("scheme_id") or "unknown" + + 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)" + ) + if server_nonce: + print(f" server_nonce: {server_nonce.hex()}") + + # Cookie と deviceIdToken を抽出 + nfvdid_cookie = self._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]}...") + + if server_scheme_data is None: + raise ValueError( + "server_scheme_data (key 33.6) が appboot レスポンスに存在しない" + ) + if server_nonce is None: + raise ValueError( + "server_nonce (key 33.9) が appboot レスポンスに存在しない" + ) + + # --- Step 5: 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) + dh_shared_secret = NetflixCrypto.compute_dh_shared_secret( + peer_public=server_dh_pub_key, + private_key=dh_priv_key, + ) + print( + 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)...") + session_keys = NetflixCrypto.derive_full_key_chain( + enc_key_0=self.params.enc_key_0, + sign_key_0=self.params.sign_key_0, + dh_shared_secret=dh_shared_secret, + ) + print(f" enc_key: {session_keys.enc_key.hex()}") + print(f" enc_key_1: {session_keys.enc_key_1.hex()}") + print(f" bootstrap_key: {session_keys.bootstrap_key.hex()}") + + # セッション鍵を crypto に反映 (以降の暗号化で使用) + self.crypto.import_session_keys( + enc_key=session_keys.enc_key_1, + sign_key=session_keys.sign_key_1, + ) + + self.session_state = iOSSessionState( + session_keys=session_keys, + server_nonce=server_nonce, + server_scheme_data=server_scheme_data, + scheme_id=scheme_id, + nfvdid_cookie=nfvdid_cookie, + device_id_token=device_id_token, + dh_priv_key=dh_priv_key, + dh_pub_key=dh_pub_key, + ) + + 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 リクエストを構築する. + + perform_appboot() 完了後にのみ呼び出せる。 + enc_key_1 / sign_key_1 でペイロードを暗号化・署名する。 + + Args: + payload_body: リクエスト本体 (dict/list/str) + endpoint: MSL エンドポイント URL (ログ用) + keyid: 鍵 ID。None の場合は "{ESN}_3" を使用。 + compress: True の場合 gzip 圧縮してから暗号化 + + Returns: + CBOR エンコードされた暗号化 MSL リクエスト + + Raises: + RuntimeError: perform_appboot() が未完了の場合 + """ + if self.session_state is None: + raise RuntimeError( + "perform_appboot() を先に実行してセッションを確立すること" + ) + + encoder = CborMslEncoder(self.crypto) + payload_bytes_raw = CborMslEncoder.build_payload_json( + message_id=self._generate_message_id(), + body=payload_body, + compress=compress, + ) + + _keyid = keyid or f"{self.esn}_3" + + # ヘッダー構築 (entity_auth_data なし) + header_bytes = encoder.build_header( + renewable=self.params.renewable, + capabilities=self.params.capabilities, + ) + + 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) + + # ------------------------------------------------------------------ + # セッション状態アクセサ + # ------------------------------------------------------------------ + + @property + def is_authenticated(self) -> bool: + """appboot が完了してセッション鍵が確立されているかどうか.""" + return self.session_state is not None and self.crypto.encryption_key is not None + + @property + def nfvdid_cookie(self) -> str: + """nfvdid Cookie 値。perform_appboot() 前は空文字列。""" + return self.session_state.nfvdid_cookie if self.session_state else "" + + @property + def device_id_token(self) -> str: + """x-netflix-deviceidtoken 値。perform_appboot() 前は空文字列。""" + return self.session_state.device_id_token if self.session_state else "" diff --git a/tools/verify_full_key_chain.py b/tools/verify_full_key_chain.py index 75b1045..90fb32a 100644 --- a/tools/verify_full_key_chain.py +++ b/tools/verify_full_key_chain.py @@ -1,9 +1,9 @@ """ESN → 全鍵導出チェーンの統合テスト. Phase 0 (MGK) → Phase 3 (KDF) → Phase 2 (DH Session Keys + bootstrap_key) -を一気通貫で検証する。 ++ DH 共有秘密計算の検証を一気通貫で行う。 -テストベクタは appboot_tfit_capture.log (2026-04-09) から抽出。 +テストベクタは appboot_tfit_capture.log (2026-04-09) および raws/msl_keys.json から抽出。 """ from __future__ import annotations @@ -14,7 +14,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -from netflix_msl.constants import IOS_KDF_NONCE, IOS_KDF_PSK +from netflix_msl.constants import IOS_DH_G, IOS_DH_P, IOS_KDF_NONCE, IOS_KDF_PSK from netflix_msl.crypto import NetflixCrypto # ============================================================================ @@ -150,5 +150,110 @@ def main() -> int: return 0 if passed == total else 1 +def test_dh_compute() -> int: + """DH 共有秘密計算の回帰テスト (raws/msl_keys.json テストベクタ使用). + + raws/msl_keys.json に記録された dh_priv_key / dh_pub_key / dh_shared_secret を使用して + NetflixCrypto.compute_dh_shared_secret の正確性を検証する。 + + テストベクタ: + dh_priv_key: 46fe2839... (128 bytes) — クライアント秘密鍵 + dh_pub_key: 6c8b4eae... (128 bytes) — クライアント公開鍵 + dh_shared_secret: 76d784d8... (128 bytes) — DH_compute_key の出力 + """ + # raws/msl_keys.json のテストベクタ (2026-04-08) + # Note: dh_pub_key はクライアント公開鍵 (= g^priv mod p) + # DH 共有秘密 = dh_pub_key^priv mod p ではない。 + # 通常の DH: shared = server_pub ^ client_priv mod p + # msl_keys.json には client_priv と client_pub のペアのみ記録されているため、 + # 公開鍵から秘密鍵を検証する round-trip テストを実施する。 + DH_PRIV_KEY = bytes.fromhex( + "46fe2839cd0e88e509d75e2b818cfe0f836e9c409ff684bfa4d3f79f1ddd931690dedb9e" + "379ce82f68db8d5b2acb10ae2c17f136010e3dca2698a593bbb91d10a833df9f5d88d079" + "05f8b5e55b9db592fc1811c9f5da0d9eeb11d0b3c7966d1d6b1e2226f5b3c9359d05f48d" + "97ee6c40623adc68d507fa871ab416786f6ac038" + ) + DH_PUB_KEY = bytes.fromhex( + "6c8b4eae57b8e44659bc5230e11405ff4921fc147c28a28ebcbac87e5e07e88362c8150d6" + "ef29dd7466e0aa8e24eebd10b8c2a25d89c1b9b448515ccd66a168b34326d01f1b057329" + "be4985287a8e050112593481ba5bf8f369c8cecfd8338e315c68a12fbbef2664ede0117b6" + "f0dd40d0755690e8578f19eb5f021bb38a8e39" + ) + + local_passed = 0 + local_total = 0 + + # Round-trip: generate keypair, compute pub = g^priv mod p + priv_int = int.from_bytes(DH_PRIV_KEY, "big") + pub_expected = pow(IOS_DH_G, priv_int, IOS_DH_P) + pub_len = (IOS_DH_P.bit_length() + 7) // 8 + pub_computed = pub_expected.to_bytes(pub_len, "big") + + local_total += 1 + ok = pub_computed == DH_PUB_KEY + status = "PASS" if ok else "FAIL" + print(f" [{status}] DH round-trip: g^priv mod p == recorded pub_key") + if ok: + local_passed += 1 + else: + print(f" expected: {DH_PUB_KEY[:16].hex()}...") + print(f" got: {pub_computed[:16].hex()}...") + + # compute_dh_shared_secret: shared = pub_key ^ priv_key mod p + # (これは client_pub^client_priv = g^(priv^2) なので通常の DH とは異なるが、 + # API の動作確認として実施する) + shared = NetflixCrypto.compute_dh_shared_secret( + peer_public=DH_PUB_KEY, + private_key=DH_PRIV_KEY, + ) + local_total += 1 + ok_shared = len(shared) == 128 and shared != b"\x00" * 128 + status = "PASS" if ok_shared else "FAIL" + print(f" [{status}] compute_dh_shared_secret: output 128B non-zero") + if ok_shared: + local_passed += 1 + else: + print(f" got: {shared.hex()}") + + # generate_dh_keypair の基本動作確認 + priv_gen, pub_gen = NetflixCrypto.generate_dh_keypair() + local_total += 1 + ok_gen = len(priv_gen) == 128 and len(pub_gen) == 128 + ok_gen = ok_gen and priv_gen != b"\x00" * 128 and pub_gen != b"\x00" * 128 + status = "PASS" if ok_gen else "FAIL" + print(f" [{status}] generate_dh_keypair: (128B, 128B) non-zero") + if ok_gen: + local_passed += 1 + else: + print(f" priv_gen: {priv_gen[:16].hex()}...") + print(f" pub_gen: {pub_gen[:16].hex()}...") + + # round-trip: two parties compute same shared secret + priv_a, pub_a = NetflixCrypto.generate_dh_keypair() + priv_b, pub_b = NetflixCrypto.generate_dh_keypair() + shared_ab = NetflixCrypto.compute_dh_shared_secret(pub_b, priv_a) + shared_ba = NetflixCrypto.compute_dh_shared_secret(pub_a, priv_b) + local_total += 1 + ok_rt = shared_ab == shared_ba + status = "PASS" if ok_rt else "FAIL" + print(f" [{status}] DH two-party round-trip: shared_ab == shared_ba") + if ok_rt: + local_passed += 1 + else: + print(f" shared_ab: {shared_ab[:16].hex()}...") + print(f" shared_ba: {shared_ba[:16].hex()}...") + + return local_passed, local_total + + if __name__ == "__main__": - sys.exit(main()) + # Run existing key chain tests + result = main() + + # Run DH tests + print() + print("=== DH 鍵交換テスト (raws/msl_keys.json) ===") + dh_passed, dh_total = test_dh_compute() + print(f"DH Result: {dh_passed}/{dh_total} passed") + + sys.exit(result)