From 001c33b8d886ff5bc2f15139c44b47fece7f303b Mon Sep 17 00:00:00 2001 From: Pari Malam Date: Fri, 29 May 2026 20:53:59 +0800 Subject: [PATCH] refactored --- .gitignore | 3 +- main.py | 1511 +++++++++------------------------------- modules/helpers.py | 512 ++++++++++++++ modules/msl_android.py | 623 +++++++---------- modules/msl_base.py | 502 +++++++++++++ modules/msl_mgk.py | 799 +++++++++++---------- modules/msl_tv.py | 471 ++++--------- modules/msl_web.py | 297 +++----- 8 files changed, 2244 insertions(+), 2474 deletions(-) create mode 100644 modules/helpers.py create mode 100644 modules/msl_base.py diff --git a/.gitignore b/.gitignore index 2f59b36..423ea9c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ cookies/* venv/* output/* .vscode/* -modules/__pycache__/ \ No newline at end of file +modules/__pycache__/ +__pycache__/ \ No newline at end of file diff --git a/main.py b/main.py index 8e413d4..9f6c350 100644 --- a/main.py +++ b/main.py @@ -9,8 +9,20 @@ from urllib.parse import quote, urlencode from Crypto.Cipher import AES from Crypto.Util.Padding import unpad from pywidevine import Cdm as WidevineCdm, Device as WidevineDevice -from modules import MSL_ANDROID, MSL_IOS, MSL_TV, MSL_WEB, MSL_MGK -from modules.msl_mgk import UserAuthentication +from modules.msl_android import MSL_ANDROID +from modules.msl_ios import MSL_IOS +from modules.msl_tv import MSL_TV +from modules.msl_web import MSL_WEB +from modules.msl_mgk import MSL_MGK +from modules.helpers import ( + ensure_output_dir, restore_auth_cookies, get_nfvdid, get_flow_session_cookies, + save_session_cookies, build_cookie_header, apply_set_cookie_headers, + dedupe_important_cookies, collect_important_cookies, generate_hex_id, + generate_netflix_uuid, generate_request_id, generate_esn_random_suffix, + decrypt_msl_header, extract_clcs_session_id, extract_rendition_id, + parse_flow_data, parse_msl_payload, extract_useridtoken_from_payload, + build_msl_trace_event, extract_key_id_from_mastertoken, request_args_to_dict, +) from modules.config import setup_config logging.basicConfig(level=logging.INFO, format="%(name)s - %(levelname)s - %(message)s") @@ -20,16 +32,200 @@ config = setup_config() EMAIL = config["NETFLIX"]["EMAIL"] PASSWORD = config["NETFLIX"]["PASSWORD"] +def setup_session(verify_tls: bool = True) -> requests.Session: + session = requests.Session() + session.verify = verify_tls + session.headers.update({ + "User-Agent": "Mozilla/5.0", + "Accept": "*/*", + }) + return session + # ====================================================================== # ANDROID # ====================================================================== +def run_android_rsa(new_msl: bool = False, no_verify: bool = False): + logger = logging.getLogger('ANDROID MSL RSA') + output_dir = ensure_output_dir("android") + msl_cache_path = output_dir / "msl_keys_cache_android_rsa.json" + auth_cookies_path = output_dir / "netflix_auth_cookies_rsa.json" + useridtoken_path = output_dir / "netflix_auth_useridtoken_rsa.json" + tokens_output_path = output_dir / "netflix_auth_tokens_rsa.json" + + # NFCDCH-02-* ESN is accepted by the Android FTL endpoint without a WVD + esn = f"NFCDCH-02-{''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(32))}" + user_agent = f"com.netflix.mediaclient/63988 (Linux; U; Android 15; en_US; SM-F711N; Build/AP3A.240905.015.A2; Cronet/143.0.7445.0)" + device_model = "SM-F711N" + + session = setup_session(verify_tls=True) + + response = session.post( + "https://android15.appboot.netflix.com/appboot/NFANDROID1-PRV-P-", + params={"keyVersion": "1"}, + headers={ + "Host": "android15.appboot.netflix.com", + "X-Netflix.Request.Client.Context": '{"appView":"unknown","appState":"foreground"}', + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": user_agent, + "Accept-Encoding": "gzip, deflate, br", + }, + timeout=30, + ) + nfvdid = get_nfvdid(session, response) + logger.info("Initial nfvdid cookie obtained") + + msl_headers = MSL_ANDROID.build_request_headers( + request_name="getProxyEsn", + user_agent=user_agent, + referer=None, + esn=esn, + expiry_timeout=12750, + host="android15.prod.cloud.netflix.com", + language="en-US,en", + device_model=quote(device_model, safe=""), + extra_headers={ + "Accept-Encoding": "gzip, deflate, br", + "Content-Encoding": "msl_v1", + "x-netflix.zuul.brotli.allowed": "true", + "x-netflix.appver": "9.60.0", + "x-netflix.clienttype": "samurai", + "x-netflix.request.client.context": '{"appView":"unknown","appState":"foreground"}', + "x-netflix.esnprefix": "NFANDROID1-PRV-P-", + "x-netflix.request.uuid": f"{generate_hex_id(8)}-{generate_hex_id(4)}-{generate_hex_id(4)}-{generate_hex_id(4)}-{generate_hex_id(12)}", + "x-netflix.androidapi": "35", + "x-netflix.deviceformfactor": "PHONE", + "x-netflix.devicememorylevel": "HIGH", + "x-netflix.request.attempt": "1", + "x-netflix.request.id": generate_hex_id(32), + "Content-Type": "application/json", + "x-netflix.client.request.name": "getProxyEsn", + "x-netflix.request.routing": '{"path":"\\/nq\\/android\\/playback\\/~1.0.0\\/router"}', + "user-agent": user_agent, + }, + ) + + logger.info("Performing RSA/ASYMMETRIC_WRAPPED MSL handshake (no WVD needed)") + msl_keys = MSL_ANDROID.rsa_handshake( + msl_keys_path=str(msl_cache_path), + session=session, + sender=esn, + new_msl=new_msl, + cookies={"nfvdid": nfvdid}, + endpoint="https://android.prod.ftl.netflix.com/nq/androidui/pbo_license/~1.0.0/router", + headers=msl_headers, + ) + + msl_client = MSL_ANDROID( + session=session, + keys=msl_keys, + message_id=random.randint(0, 2**52), + sender=esn, + drm="widevine", + ) + + logger.info("MSL RSA key exchange completed") + + # The NFCDCH-02-* ESN triggers the web CLCS auth flow (not samurai useridtoken). + # After the MSL handshake the HTTP session carries Netflix cookies, so we use + # the same CLCSScreenUpdate GraphQL path that run_web() uses. + logger.info("Fetching login page and extracting CLCS session context") + login_response = session.get("https://www.netflix.com/login", timeout=30) + login_html = login_response.text + + clcs_session_id = None + rendition_id = None + patterns = [ + r'clcsSessionId[\\"\'": ]+([0-9a-f\-]{36})', + r'(?= {"tokendata", "signature"}: - useridtoken = current - for child in current.values(): - stack.append(child) - elif isinstance(current, list): - for item in current: - stack.append(item) + useridtoken = extract_useridtoken_from_payload(parsed_payload, payload) if useridtoken: USER_ID_TOKEN_PATH.write_text(json.dumps(useridtoken, indent=2), encoding="utf-8") @@ -1254,7 +1233,7 @@ def run_tv(wvd_path: Path, "Content-Type": "application/json", "Connection": "Keep-Alive", "x-netflix.context.sdk-version": SDK_VERSION, - "X-Netflix.request.id": "".join(random.choice("0123456789ABCDEF") for _ in range(32)), + "X-Netflix.request.id": generate_hex_id(32, uppercase=True), "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', "X-Gibbon-Cache-Control": "no-cache", "x-netflix.request.expiry.timeout": "20000", @@ -1282,11 +1261,7 @@ def run_tv(wvd_path: Path, cookie_values: List[str] = [] headers = dict(session.headers) - cookie_header_map: Dict[str, str] = {} - for cookie in session.cookies: - if cookie.name in IMPORTANT_COOKIE_NAMES and cookie.value and cookie.name not in cookie_header_map: - cookie_header_map[cookie.name] = cookie.value - cookie_header = "; ".join(f"{name}={cookie_header_map[name]}" for name in IMPORTANT_COOKIE_NAMES if name in cookie_header_map) + cookie_header = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) if cookie_header: headers["Cookie"] = cookie_header @@ -1311,32 +1286,7 @@ def run_tv(wvd_path: Path, timeout=30, ) - raw_headers = getattr(response.raw, "headers", None) - if raw_headers is not None and hasattr(raw_headers, "get_all"): - cookie_values.extend(raw_headers.get_all("Set-Cookie") or []) - header_value = response.headers.get("Set-Cookie") - if header_value and header_value not in cookie_values: - cookie_values.append(header_value) - for raw_cookie in cookie_values: - jar = SimpleCookie() - try: - jar.load(raw_cookie) - except Exception: - continue - for morsel in jar.values(): - cookie_domain = morsel["domain"] or None - cookie_path = morsel["path"] or "/" - if morsel.value == "": - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - continue - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - session.cookies.set(morsel.key, morsel.value, domain=cookie_domain, path=cookie_path, secure=bool(morsel["secure"])) + apply_set_cookie_headers(session, response, cookie_values) response.raise_for_status() init_data = response.json() @@ -1392,7 +1342,7 @@ def run_tv(wvd_path: Path, # Step 5a: signInAction on welcomeContentLanding trace_uuid = str(uuid.uuid4()) - session.headers["X-Netflix.request.id"] = "".join(random.choice("0123456789ABCDEF") for _ in range(32)) + session.headers["X-Netflix.request.id"] = generate_hex_id(32, uppercase=True) session.headers["X-Netflix.request.toplevel.uuid"] = trace_uuid session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid session.headers["X-Netflix.context.operation-name"] = "clcsLegacyMoneyballSubmit" @@ -1411,11 +1361,7 @@ def run_tv(wvd_path: Path, cookie_values = [] headers = dict(session.headers) - cookie_header_map = {} - for cookie in session.cookies: - if cookie.name in IMPORTANT_COOKIE_NAMES and cookie.value and cookie.name not in cookie_header_map: - cookie_header_map[cookie.name] = cookie.value - cookie_header = "; ".join(f"{name}={cookie_header_map[name]}" for name in IMPORTANT_COOKIE_NAMES if name in cookie_header_map) + cookie_header = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) if cookie_header: headers["Cookie"] = cookie_header @@ -1440,72 +1386,14 @@ def run_tv(wvd_path: Path, timeout=30, ) - raw_headers = getattr(response.raw, "headers", None) - if raw_headers is not None and hasattr(raw_headers, "get_all"): - cookie_values.extend(raw_headers.get_all("Set-Cookie") or []) - header_value = response.headers.get("Set-Cookie") - if header_value and header_value not in cookie_values: - cookie_values.append(header_value) - for raw_cookie in cookie_values: - jar = SimpleCookie() - try: - jar.load(raw_cookie) - except Exception: - continue - for morsel in jar.values(): - cookie_domain = morsel["domain"] or None - cookie_path = morsel["path"] or "/" - if morsel.value == "": - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - continue - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - session.cookies.set(morsel.key, morsel.value, domain=cookie_domain, path=cookie_path, secure=bool(morsel["secure"])) + apply_set_cookie_headers(session, response, cookie_values) response.raise_for_status() submit_data = response.json() mfa_nonce = session.cookies.get("netflix-mfa-nonce") log.info("netflix-mfa-nonce: %s", (mfa_nonce[:80] + "...") if mfa_nonce else "missing") - flow_update: Dict[str, str] = {} - data = submit_data.get("data", {}) - operation_key = next(iter(data.keys()), "") - inner = data.get(operation_key, {}) - screen = inner.get("screen", inner) if isinstance(inner, dict) else {} - stack = [screen] - while stack: - value = stack.pop() - if isinstance(value, dict): - tracking_info = value.get("trackingInfo") - if isinstance(tracking_info, str) and tracking_info: - try: - tracking = json.loads(tracking_info) - except Exception: - tracking = {} - if tracking.get("clcsSessionId") and not flow_update.get("clcsSessionId"): - flow_update["clcsSessionId"] = tracking.get("clcsSessionId", "") - if tracking.get("clcsRenditionId"): - flow_update["renditionId"] = tracking.get("clcsRenditionId", "") - payload_json = value.get("payloadJson") - if isinstance(payload_json, str) and payload_json: - try: - payload = json.loads(payload_json) - except Exception: - payload = {} - if payload.get("flwssn") and not flow_update.get("flowSessionId"): - flow_update["flowSessionId"] = payload.get("flwssn", "") - if payload.get("mode"): - flow_update["mode"] = payload.get("mode", "") - for child in value.values(): - stack.append(child) - elif isinstance(value, list): - for item in value: - stack.append(item) + flow_update = parse_flow_data(submit_data) flow_session_id = flow_update.get("flowSessionId", flow_session_id) clcs_session_id = flow_update.get("clcsSessionId", clcs_session_id) @@ -1513,7 +1401,7 @@ def run_tv(wvd_path: Path, # Step 5b: lrudSignInAction on webSignIn trace_uuid = str(uuid.uuid4()) - session.headers["X-Netflix.request.id"] = "".join(random.choice("0123456789ABCDEF") for _ in range(32)) + session.headers["X-Netflix.request.id"] = generate_hex_id(32, uppercase=True) session.headers["X-Netflix.request.toplevel.uuid"] = trace_uuid session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid session.headers["X-Netflix.context.operation-name"] = "clcsScreenUpdate" @@ -1532,11 +1420,7 @@ def run_tv(wvd_path: Path, cookie_values = [] headers = dict(session.headers) - cookie_header_map = {} - for cookie in session.cookies: - if cookie.name in IMPORTANT_COOKIE_NAMES and cookie.value and cookie.name not in cookie_header_map: - cookie_header_map[cookie.name] = cookie.value - cookie_header = "; ".join(f"{name}={cookie_header_map[name]}" for name in IMPORTANT_COOKIE_NAMES if name in cookie_header_map) + cookie_header = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) if cookie_header: headers["Cookie"] = cookie_header @@ -1568,40 +1452,7 @@ def run_tv(wvd_path: Path, response.raise_for_status() step_web_signin = response.json() - flow_update = {} - data = step_web_signin.get("data", {}) - operation_key = next(iter(data.keys()), "") - inner = data.get(operation_key, {}) - screen = inner.get("screen", inner) if isinstance(inner, dict) else {} - stack = [screen] - while stack: - value = stack.pop() - if isinstance(value, dict): - tracking_info = value.get("trackingInfo") - if isinstance(tracking_info, str) and tracking_info: - try: - tracking = json.loads(tracking_info) - except Exception: - tracking = {} - if tracking.get("clcsSessionId") and not flow_update.get("clcsSessionId"): - flow_update["clcsSessionId"] = tracking.get("clcsSessionId", "") - if tracking.get("clcsRenditionId"): - flow_update["renditionId"] = tracking.get("clcsRenditionId", "") - payload_json = value.get("payloadJson") - if isinstance(payload_json, str) and payload_json: - try: - payload = json.loads(payload_json) - except Exception: - payload = {} - if payload.get("flwssn") and not flow_update.get("flowSessionId"): - flow_update["flowSessionId"] = payload.get("flwssn", "") - if payload.get("mode"): - flow_update["mode"] = payload.get("mode", "") - for child in value.values(): - stack.append(child) - elif isinstance(value, list): - for item in value: - stack.append(item) + flow_update = parse_flow_data(step_web_signin) flow_session_id = flow_update.get("flowSessionId", flow_session_id) clcs_session_id = flow_update.get("clcsSessionId", clcs_session_id) @@ -1609,7 +1460,7 @@ def run_tv(wvd_path: Path, # Step 5c: submitUserIdAction on enterMemberCredentials trace_uuid = str(uuid.uuid4()) - session.headers["X-Netflix.request.id"] = "".join(random.choice("0123456789ABCDEF") for _ in range(32)) + session.headers["X-Netflix.request.id"] = generate_hex_id(32, uppercase=True) session.headers["X-Netflix.request.toplevel.uuid"] = trace_uuid session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid session.headers["X-Netflix.context.operation-name"] = "clcsScreenUpdate" @@ -1628,11 +1479,7 @@ def run_tv(wvd_path: Path, cookie_values = [] headers = dict(session.headers) - cookie_header_map = {} - for cookie in session.cookies: - if cookie.name in IMPORTANT_COOKIE_NAMES and cookie.value and cookie.name not in cookie_header_map: - cookie_header_map[cookie.name] = cookie.value - cookie_header = "; ".join(f"{name}={cookie_header_map[name]}" for name in IMPORTANT_COOKIE_NAMES if name in cookie_header_map) + cookie_header = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) if cookie_header: headers["Cookie"] = cookie_header @@ -1666,40 +1513,7 @@ def run_tv(wvd_path: Path, response.raise_for_status() step_user = response.json() - flow_update = {} - data = step_user.get("data", {}) - operation_key = next(iter(data.keys()), "") - inner = data.get(operation_key, {}) - screen = inner.get("screen", inner) if isinstance(inner, dict) else {} - stack = [screen] - while stack: - value = stack.pop() - if isinstance(value, dict): - tracking_info = value.get("trackingInfo") - if isinstance(tracking_info, str) and tracking_info: - try: - tracking = json.loads(tracking_info) - except Exception: - tracking = {} - if tracking.get("clcsSessionId") and not flow_update.get("clcsSessionId"): - flow_update["clcsSessionId"] = tracking.get("clcsSessionId", "") - if tracking.get("clcsRenditionId"): - flow_update["renditionId"] = tracking.get("clcsRenditionId", "") - payload_json = value.get("payloadJson") - if isinstance(payload_json, str) and payload_json: - try: - payload = json.loads(payload_json) - except Exception: - payload = {} - if payload.get("flwssn") and not flow_update.get("flowSessionId"): - flow_update["flowSessionId"] = payload.get("flwssn", "") - if payload.get("mode"): - flow_update["mode"] = payload.get("mode", "") - for child in value.values(): - stack.append(child) - elif isinstance(value, list): - for item in value: - stack.append(item) + flow_update = parse_flow_data(step_user) flow_session_id = flow_update.get("flowSessionId", flow_session_id) clcs_session_id = flow_update.get("clcsSessionId", clcs_session_id) @@ -1707,7 +1521,7 @@ def run_tv(wvd_path: Path, # Step 5d: usePasswordAction on loginLinkOption trace_uuid = str(uuid.uuid4()) - session.headers["X-Netflix.request.id"] = "".join(random.choice("0123456789ABCDEF") for _ in range(32)) + session.headers["X-Netflix.request.id"] = generate_hex_id(32, uppercase=True) session.headers["X-Netflix.request.toplevel.uuid"] = trace_uuid session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid session.headers["X-Netflix.context.operation-name"] = "clcsScreenUpdate" @@ -1726,11 +1540,7 @@ def run_tv(wvd_path: Path, cookie_values = [] headers = dict(session.headers) - cookie_header_map = {} - for cookie in session.cookies: - if cookie.name in IMPORTANT_COOKIE_NAMES and cookie.value and cookie.name not in cookie_header_map: - cookie_header_map[cookie.name] = cookie.value - cookie_header = "; ".join(f"{name}={cookie_header_map[name]}" for name in IMPORTANT_COOKIE_NAMES if name in cookie_header_map) + cookie_header = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) if cookie_header: headers["Cookie"] = cookie_header @@ -1763,40 +1573,7 @@ def run_tv(wvd_path: Path, response.raise_for_status() step_password_path = response.json() - flow_update = {} - data = step_password_path.get("data", {}) - operation_key = next(iter(data.keys()), "") - inner = data.get(operation_key, {}) - screen = inner.get("screen", inner) if isinstance(inner, dict) else {} - stack = [screen] - while stack: - value = stack.pop() - if isinstance(value, dict): - tracking_info = value.get("trackingInfo") - if isinstance(tracking_info, str) and tracking_info: - try: - tracking = json.loads(tracking_info) - except Exception: - tracking = {} - if tracking.get("clcsSessionId") and not flow_update.get("clcsSessionId"): - flow_update["clcsSessionId"] = tracking.get("clcsSessionId", "") - if tracking.get("clcsRenditionId"): - flow_update["renditionId"] = tracking.get("clcsRenditionId", "") - payload_json = value.get("payloadJson") - if isinstance(payload_json, str) and payload_json: - try: - payload = json.loads(payload_json) - except Exception: - payload = {} - if payload.get("flwssn") and not flow_update.get("flowSessionId"): - flow_update["flowSessionId"] = payload.get("flwssn", "") - if payload.get("mode"): - flow_update["mode"] = payload.get("mode", "") - for child in value.values(): - stack.append(child) - elif isinstance(value, list): - for item in value: - stack.append(item) + flow_update = parse_flow_data(step_password_path) flow_session_id = flow_update.get("flowSessionId", flow_session_id) clcs_session_id = flow_update.get("clcsSessionId", clcs_session_id) @@ -1806,7 +1583,7 @@ def run_tv(wvd_path: Path, log.info("Submit email and password") trace_uuid = str(uuid.uuid4()) - session.headers["X-Netflix.request.id"] = "".join(random.choice("0123456789ABCDEF") for _ in range(32)) + session.headers["X-Netflix.request.id"] = generate_hex_id(32, uppercase=True) session.headers["X-Netflix.request.toplevel.uuid"] = trace_uuid session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid session.headers["X-Netflix.context.operation-name"] = "clcsScreenUpdate" @@ -1825,11 +1602,7 @@ def run_tv(wvd_path: Path, cookie_values = [] headers = dict(session.headers) - cookie_header_map = {} - for cookie in session.cookies: - if cookie.name in IMPORTANT_COOKIE_NAMES and cookie.value and cookie.name not in cookie_header_map: - cookie_header_map[cookie.name] = cookie.value - cookie_header = "; ".join(f"{name}={cookie_header_map[name]}" for name in IMPORTANT_COOKIE_NAMES if name in cookie_header_map) + cookie_header = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) if cookie_header: headers["Cookie"] = cookie_header @@ -1862,73 +1635,13 @@ def run_tv(wvd_path: Path, timeout=30, ) - raw_headers = getattr(response.raw, "headers", None) - if raw_headers is not None and hasattr(raw_headers, "get_all"): - cookie_values.extend(raw_headers.get_all("Set-Cookie") or []) - header_value = response.headers.get("Set-Cookie") - if header_value and header_value not in cookie_values: - cookie_values.append(header_value) - for raw_cookie in cookie_values: - jar = SimpleCookie() - try: - jar.load(raw_cookie) - except Exception: - continue - for morsel in jar.values(): - cookie_domain = morsel["domain"] or None - cookie_path = morsel["path"] or "/" - if morsel.value == "": - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - continue - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - session.cookies.set(morsel.key, morsel.value, domain=cookie_domain, path=cookie_path, secure=bool(morsel["secure"])) + apply_set_cookie_headers(session, response, cookie_values) response.raise_for_status() login_data = response.json() LOGIN_RESPONSE_PATH.write_text(json.dumps(login_data, indent=2, ensure_ascii=False), encoding="utf-8") - flow_result: Dict[str, str] = {} - data = login_data.get("data", {}) - operation_key = next(iter(data.keys()), "") - inner = data.get(operation_key, {}) - screen = inner.get("screen", inner) if isinstance(inner, dict) else {} - stack = [screen] - while stack: - value = stack.pop() - if isinstance(value, dict): - tracking_info = value.get("trackingInfo") - if isinstance(tracking_info, str) and tracking_info: - try: - tracking = json.loads(tracking_info) - except Exception: - tracking = {} - if tracking.get("clcsSessionId") and not flow_result.get("clcsSessionId"): - flow_result["clcsSessionId"] = tracking.get("clcsSessionId", "") - if tracking.get("clcsRenditionId"): - flow_result["renditionId"] = tracking.get("clcsRenditionId", "") - payload_json = value.get("payloadJson") - if isinstance(payload_json, str) and payload_json: - try: - payload = json.loads(payload_json) - except Exception: - payload = {} - if payload.get("flwssn") and not flow_result.get("flowSessionId"): - flow_result["flowSessionId"] = payload.get("flwssn", "") - if payload.get("mode"): - flow_result["mode"] = payload.get("mode", "") - if value.get("membershipStatus"): - flow_result["membershipStatus"] = value.get("membershipStatus", "") - for child in value.values(): - stack.append(child) - elif isinstance(value, list): - for item in value: - stack.append(item) + flow_result = parse_flow_data(login_data) membership = flow_result.get("membershipStatus", "") flow_session_id = flow_result.get("flowSessionId", flow_session_id) @@ -1949,7 +1662,7 @@ def run_tv(wvd_path: Path, "Content-Type": "application/json", "Connection": "Keep-Alive", "x-netflix.context.sdk-version": SDK_VERSION, - "X-Netflix.request.id": "".join(random.choice("0123456789ABCDEF") for _ in range(32)), + "X-Netflix.request.id": generate_hex_id(32, uppercase=True), "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"browseTitles","appstate":"foreground","reason":"unknown"}', "X-Gibbon-Cache-Control": "no-cache", "x-netflix.request.expiry.timeout": "20000", @@ -1977,11 +1690,7 @@ def run_tv(wvd_path: Path, cookie_values = [] headers = dict(session.headers) - cookie_header_map = {} - for cookie in session.cookies: - if cookie.name in IMPORTANT_COOKIE_NAMES and cookie.value and cookie.name not in cookie_header_map: - cookie_header_map[cookie.name] = cookie.value - cookie_header = "; ".join(f"{name}={cookie_header_map[name]}" for name in IMPORTANT_COOKIE_NAMES if name in cookie_header_map) + cookie_header = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) if cookie_header: headers["Cookie"] = cookie_header @@ -2006,32 +1715,7 @@ def run_tv(wvd_path: Path, timeout=30, ) - raw_headers = getattr(response.raw, "headers", None) - if raw_headers is not None and hasattr(raw_headers, "get_all"): - cookie_values.extend(raw_headers.get_all("Set-Cookie") or []) - header_value = response.headers.get("Set-Cookie") - if header_value and header_value not in cookie_values: - cookie_values.append(header_value) - for raw_cookie in cookie_values: - jar = SimpleCookie() - try: - jar.load(raw_cookie) - except Exception: - continue - for morsel in jar.values(): - cookie_domain = morsel["domain"] or None - cookie_path = morsel["path"] or "/" - if morsel.value == "": - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - continue - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - session.cookies.set(morsel.key, morsel.value, domain=cookie_domain, path=cookie_path, secure=bool(morsel["secure"])) + apply_set_cookie_headers(session, response, cookie_values) response.raise_for_status() gsid = session.cookies.get("gsid") @@ -2126,46 +1810,14 @@ def run_tv(wvd_path: Path, headers=optional_headers, ) - payload_type = type(payload).__name__ - parsed_payload = None - text_payload = None - if isinstance(payload, dict): - payload_type = "msl_payload" - parsed_payload = payload - elif isinstance(payload, list): - payload_type = "json_array" - parsed_payload = {"items": payload} - elif isinstance(payload, str): - cleaned = payload.rstrip("\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10") - text_payload = cleaned - try: - maybe_json = json.loads(cleaned) - if isinstance(maybe_json, dict): - parsed_payload = maybe_json - payload_type = "text" - except Exception: - payload_type = "text" + payload_type, parsed_payload, text_payload = parse_msl_payload(payload) - event = { - "_type": "decrypt", - "_mslId": msl.message_id, - "_timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"), - "_dataType": payload_type, - } - if text_payload is not None: - event["_text"] = text_payload - if parsed_payload is not None: - event["_payload"] = parsed_payload + event = build_msl_trace_event(msl.message_id, "", payload_type, parsed_payload, text_payload) MSL_TRACE.append(event) if isinstance(header, dict) and "headerdata" in header: try: - encrypted_header = json.loads(base64.b64decode(header["headerdata"])) - iv = base64.b64decode(encrypted_header["iv"]) - ciphertext = base64.b64decode(encrypted_header["ciphertext"]) - cipher = AES.new(msl.keys.encryption, AES.MODE_CBC, iv) - decrypted = unpad(cipher.decrypt(ciphertext), AES.block_size) - header_data = json.loads(decrypted.decode("utf-8")) + header_data = decrypt_msl_header(header["headerdata"], msl.keys.encryption, msl.keys.sign) tokens = header_data.get("useridtoken") if tokens: USER_ID_TOKEN_PATH.write_text(json.dumps(tokens, indent=2), encoding="utf-8") @@ -2176,25 +1828,7 @@ def run_tv(wvd_path: Path, log.warning("Post-login MSL refresh failed: %s", exc) log.info("Save filtered cookies") - preferred: Dict[str, Any] = {} - for cookie in session.cookies: - if cookie.name not in IMPORTANT_COOKIE_NAMES: - continue - current = preferred.get(cookie.name) - score = (cookie.domain == ".netflix.com", cookie.path == "/", bool(cookie.value)) - if current is None or score >= current[0]: - preferred[cookie.name] = (score, cookie) - - for cookie in list(session.cookies): - winner = preferred.get(cookie.name) - if not winner: - continue - winner_cookie = winner[1] - if (cookie.domain, cookie.path, cookie.value) != (winner_cookie.domain, winner_cookie.path, winner_cookie.value): - try: - session.cookies.clear(domain=cookie.domain, path=cookie.path, name=cookie.name) - except Exception: - pass + dedupe_important_cookies(session, IMPORTANT_COOKIE_NAMES) cookies: Dict[str, str] = {} for cookie in session.cookies: @@ -2243,13 +1877,7 @@ def run_tv(wvd_path: Path, def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): log = logging.getLogger('ANDROID TV MSL') - - from typing import Any, Dict, List - - - BASE_DIR = Path(__file__).resolve().parent - OUTPUT_DIR = BASE_DIR / "output" - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + OUTPUT_DIR = ensure_output_dir() MSL_CACHE_PATH = OUTPUT_DIR / "msl_keys_cache.json" USER_ID_TOKEN_PATH = OUTPUT_DIR / "useridtoken.json" NETFLIX_COOKIES_PATH = OUTPUT_DIR / "netflix_cookies.json" @@ -2263,7 +1891,7 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): DEVICE_NAME = "SHIELD" ANDROID_BUILD_FINGERPRINT = "12.1.9-23083 R 2025.2 android-30-JPLAYER2 ninja_6==NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/7825230_4040.2147:user/release-keys" APP_VERSION = "UI-release-20260408_44798-gibbon-r100-aui-nrdjs=v3.12.55" - ESN = f"NFANDROID2-PRV-NVIDIASHIELDANDROIDTV2019-NVIDISHIELD=ANDROID=TV-11233-{''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(64))}" + ESN = f"NFANDROID2-PRV-NVIDIASHIELDANDROIDTV2019-NVIDISHIELD=ANDROID=TV-11233-{generate_esn_random_suffix(64)}" IMPORTANT_COOKIE_NAMES = ("netflix-mfa-nonce", "NetflixId", "SecureNetflixId", "nfvdid", "gsid") @@ -2428,7 +2056,7 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): "User-Agent": ua, "Accept": "*/*", "x-netflix.context.sdk-version": "2025.2.3.0", - "X-Netflix.request.id": "".join(random.choice("0123456789ABCDEF") for _ in range(32)), + "X-Netflix.request.id": generate_hex_id(32, uppercase=True), "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', "X-Netflix.Request.NonJson.Headers": "true", "x-netflix.client.netjs.version": "3.0.5", @@ -2494,7 +2122,7 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): "Accept": "*/*", "Accept-Encoding": "deflate,gzip", "x-netflix.context.sdk-version": "2025.2.3.0", - "X-Netflix.request.id": "".join(random.choice("0123456789ABCDEF") for _ in range(32)), + "X-Netflix.request.id": generate_hex_id(32, uppercase=True), "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', "X-Gibbon-Cache-Control": "no-cache", "X-Netflix.request.expiry.timeout": "20000", @@ -2622,67 +2250,16 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): headers=mint_headers, ) - payload_type = type(payload).__name__ - parsed_payload = None - text_payload = None + payload_type, parsed_payload, text_payload = parse_msl_payload(payload) - if isinstance(payload, dict): - payload_type = "msl_payload" - parsed_payload = payload - elif isinstance(payload, list): - payload_type = "json_array" - parsed_payload = {"items": payload} - elif isinstance(payload, str): - cleaned = payload.rstrip("\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10") - try: - maybe_json = json.loads(cleaned) - except Exception: - maybe_json = cleaned - text_payload = cleaned - if isinstance(maybe_json, dict): - parsed_payload = maybe_json - payload_type = "text" - else: - payload_type = "text" + key_id = extract_key_id_from_mastertoken(msl.keys.mastertoken) if msl.keys.mastertoken else "" - key_id = "" - if msl.keys.mastertoken: - token_data = json.loads(base64.b64decode(msl.keys.mastertoken["tokendata"]).decode("utf-8")) - sequence_number = str(token_data.get("sequencenumber", "")) - key_id = sequence_number.encode("utf-8").hex() + event = build_msl_trace_event( + msl.message_id, key_id, payload_type, parsed_payload, text_payload, + extra_fields={"_iv": None, "_ciphertextLen": None, "_plaintextLen": len(text_payload.encode("utf-8")) if isinstance(text_payload, str) else None}, + ) - event: Dict[str, Any] = { - "_type": "decrypt", - "_mslId": msl.message_id, - "_timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"), - "_keyId": key_id, - "_iv": None, - "_ciphertextLen": None, - "_plaintextLen": len(text_payload.encode("utf-8")) if isinstance(text_payload, str) else None, - "_dataType": payload_type, - } - if text_payload is not None: - event["_text"] = text_payload - if parsed_payload is not None: - event["_payload"] = parsed_payload - - useridtoken = None - servicetokens: List[Dict[str, Any]] = [] - stack = [parsed_payload if parsed_payload is not None else payload] - - while stack: - current = stack.pop() - if isinstance(current, dict): - if set(current.keys()) >= {"tokendata", "signature"}: - if useridtoken is None: - useridtoken = current - else: - servicetokens.append(current) - for child in current.values(): - stack.append(child) - elif isinstance(current, list): - for item in current: - stack.append(item) + useridtoken = extract_useridtoken_from_payload(parsed_payload, payload) if useridtoken: USER_ID_TOKEN_PATH.write_text(json.dumps(useridtoken, indent=2), encoding="utf-8") @@ -2752,7 +2329,7 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): "Accept": "*/*", "Accept-Encoding": "deflate,gzip", "x-netflix.context.sdk-version": "2025.2.3.0", - "X-Netflix.request.id": "".join(random.choice("0123456789ABCDEF") for _ in range(32)), + "X-Netflix.request.id": generate_hex_id(32, uppercase=True), "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', "X-Gibbon-Cache-Control": "no-cache", "X-Netflix.request.expiry.timeout": "20000", @@ -2852,67 +2429,16 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): headers=optional_headers, ) - payload_type = type(payload).__name__ - parsed_payload = None - text_payload = None + payload_type, parsed_payload, text_payload = parse_msl_payload(payload) - if isinstance(payload, dict): - payload_type = "msl_payload" - parsed_payload = payload - elif isinstance(payload, list): - payload_type = "json_array" - parsed_payload = {"items": payload} - elif isinstance(payload, str): - cleaned = payload.rstrip("\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10") - try: - maybe_json = json.loads(cleaned) - except Exception: - maybe_json = cleaned - text_payload = cleaned - if isinstance(maybe_json, dict): - parsed_payload = maybe_json - payload_type = "text" - else: - payload_type = "text" + key_id = extract_key_id_from_mastertoken(msl.keys.mastertoken) if msl.keys.mastertoken else "" - key_id = "" - if msl.keys.mastertoken: - token_data = json.loads(base64.b64decode(msl.keys.mastertoken["tokendata"]).decode("utf-8")) - sequence_number = str(token_data.get("sequencenumber", "")) - key_id = sequence_number.encode("utf-8").hex() + event = build_msl_trace_event( + msl.message_id, key_id, payload_type, parsed_payload, text_payload, + extra_fields={"_iv": None, "_ciphertextLen": None, "_plaintextLen": len(text_payload.encode("utf-8")) if isinstance(text_payload, str) else None}, + ) - event = { - "_type": "decrypt", - "_mslId": msl.message_id, - "_timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"), - "_keyId": key_id, - "_iv": None, - "_ciphertextLen": None, - "_plaintextLen": len(text_payload.encode("utf-8")) if isinstance(text_payload, str) else None, - "_dataType": payload_type, - } - if text_payload is not None: - event["_text"] = text_payload - if parsed_payload is not None: - event["_payload"] = parsed_payload - - useridtoken = None - servicetokens = [] - stack = [parsed_payload if parsed_payload is not None else payload] - - while stack: - current = stack.pop() - if isinstance(current, dict): - if set(current.keys()) >= {"tokendata", "signature"}: - if useridtoken is None: - useridtoken = current - else: - servicetokens.append(current) - for child in current.values(): - stack.append(child) - elif isinstance(current, list): - for item in current: - stack.append(item) + useridtoken = extract_useridtoken_from_payload(parsed_payload, payload) if useridtoken: USER_ID_TOKEN_PATH.write_text(json.dumps(useridtoken, indent=2), encoding="utf-8") @@ -2940,7 +2466,7 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): "Content-Type": "application/json", "Connection": "Keep-Alive", "x-netflix.context.sdk-version": "2025.2.3.0", - "X-Netflix.request.id": "".join(random.choice("0123456789ABCDEF") for _ in range(32)), + "X-Netflix.request.id": generate_hex_id(32, uppercase=True), "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', "X-Gibbon-Cache-Control": "no-cache", "x-netflix.request.expiry.timeout": "20000", @@ -2988,109 +2514,23 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): "supportedVideoFormat": "mp4", }, } - cookie_header_map: Dict[str, str] = {} - for cookie in session.cookies: - if cookie.name in IMPORTANT_COOKIE_NAMES and cookie.value and cookie.name not in cookie_header_map: - cookie_header_map[cookie.name] = cookie.value - cookie_header = "; ".join(f"{name}={cookie_header_map[name]}" for name in IMPORTANT_COOKIE_NAMES if name in cookie_header_map) + cookie_header = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) headers = dict(session.headers) if cookie_header: headers["Cookie"] = cookie_header response = session.post(graphql_url, json=body, headers=headers, timeout=30) - raw_headers = getattr(response.raw, "headers", None) - if raw_headers is not None and hasattr(raw_headers, "get_all"): - cookie_values.extend(raw_headers.get_all("Set-Cookie") or []) - header_value = response.headers.get("Set-Cookie") - if header_value and header_value not in cookie_values: - cookie_values.append(header_value) + apply_set_cookie_headers(session, response, cookie_values) - for raw_cookie in cookie_values: - jar = SimpleCookie() - try: - jar.load(raw_cookie) - except Exception: - continue - for morsel in jar.values(): - cookie_domain = morsel["domain"] or None - cookie_path = morsel["path"] or "/" - if morsel.value == "": - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - continue - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - session.cookies.set(morsel.key, morsel.value, domain=cookie_domain, path=cookie_path, secure=bool(morsel["secure"])) - - preferred: Dict[str, Any] = {} - for cookie in session.cookies: - if cookie.name not in IMPORTANT_COOKIE_NAMES: - continue - current = preferred.get(cookie.name) - score = (cookie.domain == ".netflix.com", cookie.path == "/", bool(cookie.value)) - if current is None or score >= current[0]: - preferred[cookie.name] = (score, cookie) - - for cookie in list(session.cookies): - winner = preferred.get(cookie.name) - if not winner: - continue - winner_cookie = winner[1] - if (cookie.domain, cookie.path, cookie.value) != (winner_cookie.domain, winner_cookie.path, winner_cookie.value): - try: - session.cookies.clear(domain=cookie.domain, path=cookie.path, name=cookie.name) - except Exception: - pass + dedupe_important_cookies(session, IMPORTANT_COOKIE_NAMES) response.raise_for_status() init_data = response.json() if "errors" in init_data: raise RuntimeError(json.dumps(init_data["errors"], indent=2)) - flow: Dict[str, str] = {} - data = init_data.get("data", {}) - operation_key = next(iter(data.keys()), "") - inner = data.get(operation_key, {}) - screen = inner.get("screen", inner) if isinstance(inner, dict) else {} - - stack = [screen] - while stack: - value = stack.pop() - if isinstance(value, dict): - tracking_info = value.get("trackingInfo") - if isinstance(tracking_info, str) and tracking_info: - try: - tracking = json.loads(tracking_info) - except Exception: - tracking = {} - if tracking.get("clcsSessionId") and not flow.get("clcsSessionId"): - flow["clcsSessionId"] = tracking.get("clcsSessionId", "") - if tracking.get("clcsRenditionId"): - flow["renditionId"] = tracking.get("clcsRenditionId", "") - payload_json = value.get("payloadJson") - if isinstance(payload_json, str) and payload_json: - try: - payload = json.loads(payload_json) - except Exception: - payload = {} - if payload.get("flwssn") and not flow.get("flowSessionId"): - flow["flowSessionId"] = payload.get("flwssn", "") - if payload.get("mode"): - flow["mode"] = payload.get("mode", "") - if payload.get("flow"): - flow["flow"] = payload.get("flow", "") - if value.get("membershipStatus"): - flow["membershipStatus"] = value.get("membershipStatus", "") - for child in value.values(): - stack.append(child) - elif isinstance(value, list): - for item in value: - stack.append(item) + flow = parse_flow_data(init_data) flow_session_id = flow.get("flowSessionId", "") clcs_session_id = flow.get("clcsSessionId", "") @@ -3116,7 +2556,7 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): "Content-Type": "application/json", "Connection": "Keep-Alive", "x-netflix.context.sdk-version": "2025.2.3.0", - "X-Netflix.request.id": "".join(random.choice("0123456789ABCDEF") for _ in range(32)), + "X-Netflix.request.id": generate_hex_id(32, uppercase=True), "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', "X-Gibbon-Cache-Control": "no-cache", "x-netflix.request.expiry.timeout": "20000", @@ -3142,13 +2582,7 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): } ) - request_args_dict: Dict[str, Any] = {} - for arg in REQUEST_ARGS: - value = arg["value"] - if "stringValue" in value: - request_args_dict[arg["name"]] = value["stringValue"] - elif "booleanValue" in value: - request_args_dict[arg["name"]] = value["booleanValue"] + request_args_dict = request_args_to_dict(REQUEST_ARGS) server_state = json.dumps( { @@ -3184,107 +2618,21 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): "serverState": server_state, }, } - cookie_header_map = {} - for cookie in session.cookies: - if cookie.name in IMPORTANT_COOKIE_NAMES and cookie.value and cookie.name not in cookie_header_map: - cookie_header_map[cookie.name] = cookie.value - cookie_header = "; ".join(f"{name}={cookie_header_map[name]}" for name in IMPORTANT_COOKIE_NAMES if name in cookie_header_map) + cookie_header = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) headers = dict(session.headers) if cookie_header: headers["Cookie"] = cookie_header response = session.post(graphql_url, json=body, headers=headers, timeout=30) - raw_headers = getattr(response.raw, "headers", None) - if raw_headers is not None and hasattr(raw_headers, "get_all"): - cookie_values.extend(raw_headers.get_all("Set-Cookie") or []) - header_value = response.headers.get("Set-Cookie") - if header_value and header_value not in cookie_values: - cookie_values.append(header_value) + apply_set_cookie_headers(session, response, cookie_values) - for raw_cookie in cookie_values: - jar = SimpleCookie() - try: - jar.load(raw_cookie) - except Exception: - continue - for morsel in jar.values(): - cookie_domain = morsel["domain"] or None - cookie_path = morsel["path"] or "/" - if morsel.value == "": - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - continue - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - session.cookies.set(morsel.key, morsel.value, domain=cookie_domain, path=cookie_path, secure=bool(morsel["secure"])) - - preferred = {} - for cookie in session.cookies: - if cookie.name not in IMPORTANT_COOKIE_NAMES: - continue - current = preferred.get(cookie.name) - score = (cookie.domain == ".netflix.com", cookie.path == "/", bool(cookie.value)) - if current is None or score >= current[0]: - preferred[cookie.name] = (score, cookie) - - for cookie in list(session.cookies): - winner = preferred.get(cookie.name) - if not winner: - continue - winner_cookie = winner[1] - if (cookie.domain, cookie.path, cookie.value) != (winner_cookie.domain, winner_cookie.path, winner_cookie.value): - try: - session.cookies.clear(domain=cookie.domain, path=cookie.path, name=cookie.name) - except Exception: - pass + dedupe_important_cookies(session, IMPORTANT_COOKIE_NAMES) response.raise_for_status() submit_data = response.json() - flow2: Dict[str, str] = {} - data = submit_data.get("data", {}) - operation_key = next(iter(data.keys()), "") - inner = data.get(operation_key, {}) - screen = inner.get("screen", inner) if isinstance(inner, dict) else {} - - stack = [screen] - while stack: - value = stack.pop() - if isinstance(value, dict): - tracking_info = value.get("trackingInfo") - if isinstance(tracking_info, str) and tracking_info: - try: - tracking = json.loads(tracking_info) - except Exception: - tracking = {} - if tracking.get("clcsSessionId") and not flow2.get("clcsSessionId"): - flow2["clcsSessionId"] = tracking.get("clcsSessionId", "") - if tracking.get("clcsRenditionId"): - flow2["renditionId"] = tracking.get("clcsRenditionId", "") - payload_json = value.get("payloadJson") - if isinstance(payload_json, str) and payload_json: - try: - payload = json.loads(payload_json) - except Exception: - payload = {} - if payload.get("flwssn") and not flow2.get("flowSessionId"): - flow2["flowSessionId"] = payload.get("flwssn", "") - if payload.get("mode"): - flow2["mode"] = payload.get("mode", "") - if payload.get("flow"): - flow2["flow"] = payload.get("flow", "") - if value.get("membershipStatus"): - flow2["membershipStatus"] = value.get("membershipStatus", "") - for child in value.values(): - stack.append(child) - elif isinstance(value, list): - for item in value: - stack.append(item) + flow2 = parse_flow_data(submit_data) rendition_id = flow2.get("renditionId", rendition_id) log.info("Rendition: %s", rendition_id) @@ -3339,7 +2687,7 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): "Content-Type": "application/json", "Connection": "Keep-Alive", "x-netflix.context.sdk-version": "2025.2.3.0", - "X-Netflix.request.id": "".join(random.choice("0123456789ABCDEF") for _ in range(32)), + "X-Netflix.request.id": generate_hex_id(32, uppercase=True), "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', "X-Gibbon-Cache-Control": "no-cache", "x-netflix.request.expiry.timeout": "20000", @@ -3402,64 +2750,15 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): "serverState": server_state, }, } - cookie_header_map = {} - for cookie in session.cookies: - if cookie.name in IMPORTANT_COOKIE_NAMES and cookie.value and cookie.name not in cookie_header_map: - cookie_header_map[cookie.name] = cookie.value - cookie_header = "; ".join(f"{name}={cookie_header_map[name]}" for name in IMPORTANT_COOKIE_NAMES if name in cookie_header_map) + cookie_header = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) headers = dict(session.headers) if cookie_header: headers["Cookie"] = cookie_header response = session.post(graphql_url, json=body, headers=headers, timeout=30) - raw_headers = getattr(response.raw, "headers", None) - if raw_headers is not None and hasattr(raw_headers, "get_all"): - cookie_values.extend(raw_headers.get_all("Set-Cookie") or []) - header_value = response.headers.get("Set-Cookie") - if header_value and header_value not in cookie_values: - cookie_values.append(header_value) - - for raw_cookie in cookie_values: - jar = SimpleCookie() - try: - jar.load(raw_cookie) - except Exception: - continue - for morsel in jar.values(): - cookie_domain = morsel["domain"] or None - cookie_path = morsel["path"] or "/" - if morsel.value == "": - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - continue - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - session.cookies.set(morsel.key, morsel.value, domain=cookie_domain, path=cookie_path, secure=bool(morsel["secure"])) - - preferred = {} - for cookie in session.cookies: - if cookie.name not in IMPORTANT_COOKIE_NAMES: - continue - current = preferred.get(cookie.name) - score = (cookie.domain == ".netflix.com", cookie.path == "/", bool(cookie.value)) - if current is None or score >= current[0]: - preferred[cookie.name] = (score, cookie) - - for cookie in list(session.cookies): - winner = preferred.get(cookie.name) - if not winner: - continue - winner_cookie = winner[1] - if (cookie.domain, cookie.path, cookie.value) != (winner_cookie.domain, winner_cookie.path, winner_cookie.value): - try: - session.cookies.clear(domain=cookie.domain, path=cookie.path, name=cookie.name) - except Exception: - pass + apply_set_cookie_headers(session, response, cookie_values) + dedupe_important_cookies(session, IMPORTANT_COOKIE_NAMES) response.raise_for_status() poll_data = response.json() @@ -3514,7 +2813,7 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): "Content-Type": "application/json", "Connection": "Keep-Alive", "x-netflix.context.sdk-version": "2025.2.3.0", - "X-Netflix.request.id": "".join(random.choice("0123456789ABCDEF") for _ in range(32)), + "X-Netflix.request.id": generate_hex_id(32, uppercase=True), "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', "X-Gibbon-Cache-Control": "no-cache", "x-netflix.request.expiry.timeout": "20000", @@ -3579,107 +2878,21 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): "serverState": server_state, }, } - cookie_header_map = {} - for cookie in session.cookies: - if cookie.name in IMPORTANT_COOKIE_NAMES and cookie.value and cookie.name not in cookie_header_map: - cookie_header_map[cookie.name] = cookie.value - cookie_header = "; ".join(f"{name}={cookie_header_map[name]}" for name in IMPORTANT_COOKIE_NAMES if name in cookie_header_map) + cookie_header = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) headers = dict(session.headers) if cookie_header: headers["Cookie"] = cookie_header response = session.post(graphql_url, json=body, headers=headers, timeout=30) - raw_headers = getattr(response.raw, "headers", None) - if raw_headers is not None and hasattr(raw_headers, "get_all"): - cookie_values.extend(raw_headers.get_all("Set-Cookie") or []) - header_value = response.headers.get("Set-Cookie") - if header_value and header_value not in cookie_values: - cookie_values.append(header_value) + apply_set_cookie_headers(session, response, cookie_values) - for raw_cookie in cookie_values: - jar = SimpleCookie() - try: - jar.load(raw_cookie) - except Exception: - continue - for morsel in jar.values(): - cookie_domain = morsel["domain"] or None - cookie_path = morsel["path"] or "/" - if morsel.value == "": - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - continue - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - session.cookies.set(morsel.key, morsel.value, domain=cookie_domain, path=cookie_path, secure=bool(morsel["secure"])) - - preferred = {} - for cookie in session.cookies: - if cookie.name not in IMPORTANT_COOKIE_NAMES: - continue - current = preferred.get(cookie.name) - score = (cookie.domain == ".netflix.com", cookie.path == "/", bool(cookie.value)) - if current is None or score >= current[0]: - preferred[cookie.name] = (score, cookie) - - for cookie in list(session.cookies): - winner = preferred.get(cookie.name) - if not winner: - continue - winner_cookie = winner[1] - if (cookie.domain, cookie.path, cookie.value) != (winner_cookie.domain, winner_cookie.path, winner_cookie.value): - try: - session.cookies.clear(domain=cookie.domain, path=cookie.path, name=cookie.name) - except Exception: - pass + dedupe_important_cookies(session, IMPORTANT_COOKIE_NAMES) response.raise_for_status() login_data = response.json() - flow_result: Dict[str, str] = {} - data = login_data.get("data", {}) - operation_key = next(iter(data.keys()), "") - inner = data.get(operation_key, {}) - screen = inner.get("screen", inner) if isinstance(inner, dict) else {} - - stack = [screen] - while stack: - value = stack.pop() - if isinstance(value, dict): - tracking_info = value.get("trackingInfo") - if isinstance(tracking_info, str) and tracking_info: - try: - tracking = json.loads(tracking_info) - except Exception: - tracking = {} - if tracking.get("clcsSessionId") and not flow_result.get("clcsSessionId"): - flow_result["clcsSessionId"] = tracking.get("clcsSessionId", "") - if tracking.get("clcsRenditionId"): - flow_result["renditionId"] = tracking.get("clcsRenditionId", "") - payload_json = value.get("payloadJson") - if isinstance(payload_json, str) and payload_json: - try: - payload = json.loads(payload_json) - except Exception: - payload = {} - if payload.get("flwssn") and not flow_result.get("flowSessionId"): - flow_result["flowSessionId"] = payload.get("flwssn", "") - if payload.get("mode"): - flow_result["mode"] = payload.get("mode", "") - if payload.get("flow"): - flow_result["flow"] = payload.get("flow", "") - if value.get("membershipStatus"): - flow_result["membershipStatus"] = value.get("membershipStatus", "") - for child in value.values(): - stack.append(child) - elif isinstance(value, list): - for item in value: - stack.append(item) + flow_result = parse_flow_data(login_data) membership = flow_result.get("membershipStatus", "") log.info("Membership: %s", membership) @@ -3705,7 +2918,7 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): "Content-Type": "application/json", "Connection": "Keep-Alive", "x-netflix.context.sdk-version": "2025.2.3.0", - "X-Netflix.request.id": "".join(random.choice("0123456789ABCDEF") for _ in range(32)), + "X-Netflix.request.id": generate_hex_id(32, uppercase=True), "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', "X-Gibbon-Cache-Control": "no-cache", "x-netflix.request.expiry.timeout": "20000", @@ -3749,11 +2962,7 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): }, } - cookie_header_map = {} - for cookie in session.cookies: - if cookie.name in IMPORTANT_COOKIE_NAMES and cookie.value and cookie.name not in cookie_header_map: - cookie_header_map[cookie.name] = cookie.value - cookie_header = "; ".join(f"{name}={cookie_header_map[name]}" for name in IMPORTANT_COOKIE_NAMES if name in cookie_header_map) + cookie_header = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) headers = dict(session.headers) if cookie_header: headers["Cookie"] = cookie_header @@ -3767,27 +2976,6 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): if header_value and header_value not in cookie_values: cookie_values.append(header_value) - for raw_cookie in cookie_values: - jar = SimpleCookie() - try: - jar.load(raw_cookie) - except Exception: - continue - for morsel in jar.values(): - cookie_domain = morsel["domain"] or None - cookie_path = morsel["path"] or "/" - if morsel.value == "": - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - continue - try: - session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) - except Exception: - pass - session.cookies.set(morsel.key, morsel.value, domain=cookie_domain, path=cookie_path, secure=bool(morsel["secure"])) - if response.ok and session.cookies.get("gsid"): log.info("gsid: %s", f"{session.cookies.get('gsid', 'N/A')[:80]}...") else: @@ -3795,25 +2983,7 @@ def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): except Exception as exc: log.warning("Post-login gsid fetch failed: %s", exc) - preferred = {} - for cookie in session.cookies: - if cookie.name not in IMPORTANT_COOKIE_NAMES: - continue - current = preferred.get(cookie.name) - score = (cookie.domain == ".netflix.com", cookie.path == "/", bool(cookie.value)) - if current is None or score >= current[0]: - preferred[cookie.name] = (score, cookie) - - for cookie in list(session.cookies): - winner = preferred.get(cookie.name) - if not winner: - continue - winner_cookie = winner[1] - if (cookie.domain, cookie.path, cookie.value) != (winner_cookie.domain, winner_cookie.path, winner_cookie.value): - try: - session.cookies.clear(domain=cookie.domain, path=cookie.path, name=cookie.name) - except Exception: - pass + dedupe_important_cookies(session, IMPORTANT_COOKIE_NAMES) cookies: Dict[str, str] = {} for cookie in session.cookies: @@ -3863,13 +3033,7 @@ def run_web(new_msl: bool = False, no_verify: bool = False, recaptcha_token: str = ''): log = logging.getLogger('BROWSER MSL') - from typing import Any, Dict, Optional - - - - BASE_DIR = Path(__file__).resolve().parent - OUTPUT_DIR = BASE_DIR / "output" - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + OUTPUT_DIR = ensure_output_dir("browser") MSL_CACHE_PATH = OUTPUT_DIR / "msl_keys_cache_web.json" PRELOGIN_COOKIES_PATH = OUTPUT_DIR / "netflix_prelogin_cookies.json" AUTH_COOKIES_PATH = OUTPUT_DIR / "netflix_auth_cookies.json" @@ -3890,7 +3054,7 @@ def run_web(new_msl: bool = False, no_verify: bool = False, APP_VERSION = "ve300d66c" HAWKINS_VERSION = "5.16.0" UI_FLAVOR = "akira" - MSL_ESN = f"NFCDCH-02-{''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(32))}" + MSL_ESN = f"NFCDCH-02-{generate_hex_id(32, uppercase=True)}" REQUEST_CLIENT_CONTEXT = '{"appstate":"foreground"}' RECAPTCHA_SITE_KEY = "6Lf8hrcUAAAAAIpQAFW2VFjtiYnThOjZOA5xvLyR" @@ -3935,7 +3099,7 @@ def run_web(new_msl: bool = False, no_verify: bool = False, headers = { "Host": "web.prod.cloud.netflix.com", "Connection": "keep-alive", - "x-netflix.request.id": "".join(random.choice("0123456789abcdef") for _ in range(32)), + "x-netflix.request.id": generate_request_id(), "x-netflix.context.operation-name": operation_name, "x-netflix.request.originating.url": originating_url, "x-netflix.context.app-version": APP_VERSION, @@ -3972,36 +3136,8 @@ def run_web(new_msl: bool = False, no_verify: bool = False, response.raise_for_status() login_html = response.text - clcs_session_id = None - clcs_patterns = [ - r'"clcsSessionId"\s*:\s*"([0-9a-f\-]{36})"', - r'\\"clcsSessionId\\"\s*:\s*\\"([0-9a-f\-]{36})\\"', - r'"serverState"\s*:\s*"[^\"]*clcsSessionId\\":\\"([0-9a-f\-]{36})', - r'"trackingInfo"\s*:\s*"[^\"]*clcsSessionId\\":\\"([0-9a-f\-]{36})', - r'"sessionId"\s*:\s*"([0-9a-f\-]{36})"', - ] - for pattern in clcs_patterns: - match = re.search(pattern, login_html) - if match: - clcs_session_id = match.group(1) - break - - if not clcs_session_id: - raise RuntimeError("Could not extract clcsSessionId from the login page HTML") - - rendition_id = None - rendition_patterns = [ - r'"renditionId"\s*:\s*"([0-9a-f\-]{36})"', - r'\\"renditionId\\"\s*:\s*\\"([0-9a-f\-]{36})\\"', - ] - for pattern in rendition_patterns: - match = re.search(pattern, login_html) - if match: - rendition_id = match.group(1) - break - - if not rendition_id: - raise RuntimeError("Could not extract the initial renditionId from the login page HTML") + clcs_session_id = extract_clcs_session_id(login_html) + rendition_id = extract_rendition_id(login_html) screen_name = "IDENTIFICATION" screen_name = "PASSWORD_LOGIN" @@ -4054,7 +3190,7 @@ def run_web(new_msl: bool = False, no_verify: bool = False, headers = { "Host": "web.prod.cloud.netflix.com", "Connection": "keep-alive", - "x-netflix.request.id": "".join(random.choice("0123456789abcdef") for _ in range(32)), + "x-netflix.request.id": generate_request_id(), "x-netflix.context.operation-name": operation_name, "x-netflix.request.originating.url": originating_url, "x-netflix.context.app-version": APP_VERSION, @@ -4123,7 +3259,7 @@ def run_web(new_msl: bool = False, no_verify: bool = False, headers = { "Host": "web.prod.cloud.netflix.com", "Connection": "keep-alive", - "x-netflix.request.id": "".join(random.choice("0123456789abcdef") for _ in range(32)), + "x-netflix.request.id": generate_request_id(), "x-netflix.context.operation-name": operation_name, "x-netflix.request.originating.url": originating_url, "x-netflix.context.app-version": APP_VERSION, @@ -4200,7 +3336,7 @@ def run_web(new_msl: bool = False, no_verify: bool = False, headers = { "Host": "web.prod.cloud.netflix.com", "Connection": "keep-alive", - "x-netflix.request.id": "".join(random.choice("0123456789abcdef") for _ in range(32)), + "x-netflix.request.id": generate_request_id(), "x-netflix.context.operation-name": operation_name, "x-netflix.request.originating.url": originating_url, "x-netflix.context.app-version": APP_VERSION, @@ -4270,7 +3406,7 @@ def run_web(new_msl: bool = False, no_verify: bool = False, headers = { "Host": "web.prod.cloud.netflix.com", "Connection": "keep-alive", - "x-netflix.request.id": "".join(random.choice("0123456789abcdef") for _ in range(32)), + "x-netflix.request.id": generate_request_id(), "x-netflix.context.operation-name": operation_name, "x-netflix.request.originating.url": originating_url, "x-netflix.context.app-version": APP_VERSION, @@ -4309,7 +3445,7 @@ def run_web(new_msl: bool = False, no_verify: bool = False, try: final_cookies = MSL_WEB.cookiejar_to_ordered_dict(session.cookies) - req_id = "".join(random.choice("0123456789abcdef") for _ in range(32)) + req_id = generate_request_id() endpoint = ( f"{MSL_ALE_ENDPOINT}?reqAttempt=1&reqName=aleProvision&reqId={req_id}" f"&clienttype={UI_FLAVOR}&uiversion={APP_VERSION}&browsername=chrome" @@ -4346,7 +3482,7 @@ def run_web(new_msl: bool = False, no_verify: bool = False, "auth_cookies": auth_cookies, } - print(json.dumps(result, indent=2)) + #print(json.dumps(result, indent=2)) # ====================================================================== @@ -4357,9 +3493,7 @@ def run_mgk(kpekph_path: Optional[Path], esnid: str, new_msl: bool = False): log = logging.getLogger('MGK MSL') - BASE_DIR = Path(__file__).resolve().parent - OUTPUT_DIR = BASE_DIR / "output" - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + OUTPUT_DIR = ensure_output_dir("mgk") MSL_CACHE_PATH = OUTPUT_DIR / "msl_keys_cache_mgk.json" AUTH_COOKIES_PATH = OUTPUT_DIR / "netflix_auth_cookies_mgk.json" @@ -4415,15 +3549,9 @@ def run_mgk(kpekph_path: Optional[Path], esnid: str, log.exception("MGK authenticated request failed") sys.exit(1) - auth_cookies = {} - for cookie in session.cookies: - auth_cookies[cookie.name] = cookie.value - try: - AUTH_COOKIES_PATH.write_text(json.dumps(auth_cookies, indent=2), encoding="utf-8") - log.info("Authentication cookies saved to: %s", AUTH_COOKIES_PATH) + auth_cookies = save_session_cookies(session, AUTH_COOKIES_PATH, log) except Exception: - log.exception("Failed to save cookies") sys.exit(1) result = { @@ -4444,9 +3572,7 @@ def run_mgk(kpekph_path: Optional[Path], esnid: str, def main(): parser = argparse.ArgumentParser(description="Netflix MSL multi-platform login") - parser.add_argument("--platform", required=True, - choices=["android", "ios", "tv", "tv_otp", "web", "mgk"], - help="Target platform") + parser.add_argument("--platform", required=True, choices=["android", "android_rsa", "ios", "tv", "tv_otp", "web", "mgk"], help="Target platform") parser.add_argument("--wvd", type=Path, help="Path to Widevine .wvd device file") parser.add_argument("--kpekph", type=Path, default=None, help="Path to KpeKph file (mgk platform); auto-discovered if omitted") parser.add_argument("--esnid", type=str, help="ESN identity string (mgk platform)") @@ -4454,7 +3580,10 @@ def main(): parser.add_argument("--no-verify", action="store_true", help="Skip TLS verification") args = parser.parse_args() - + + if args.platform == "android_rsa": + run_android_rsa(new_msl=args.new_msl, no_verify=args.no_verify) + if args.platform == "android": if not args.wvd: parser.error("--wvd is required for android platform") diff --git a/modules/helpers.py b/modules/helpers.py new file mode 100644 index 0000000..f0845c3 --- /dev/null +++ b/modules/helpers.py @@ -0,0 +1,512 @@ +""" +helpers.py — Shared utility functions for the NF-MSL project. + +Every function here was extracted from main.py to eliminate code duplication. +No behaviour was changed; all original logic is preserved. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import random +import re +import uuid +from http.cookies import SimpleCookie +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import requests +from Crypto.Cipher import AES +from Crypto.Util.Padding import unpad + + +# --------------------------------------------------------------------------- +# Directory / file helpers +# --------------------------------------------------------------------------- + +def ensure_output_dir(platform_name, base_dir: Optional[Path] = None) -> Path: + """Create (if needed) and return the ``output`` directory under *base_dir*. + + If *base_dir* is ``None`` it defaults to the directory that contains the + calling script (i.e. ``Path(__file__).resolve().parent``). + """ + if base_dir is None: + base_dir = Path(__file__).resolve().parent + output_dir = base_dir / "output" / platform_name + output_dir.mkdir(parents=True, exist_ok=True) + return output_dir + + +def save_json(path: Path, data: Any, log: Optional[logging.Logger] = None) -> None: + """Write *data* as pretty-printed JSON to *path*.""" + try: + path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + if log: + log.info("Saved %s", path.name) + except Exception: + if log: + log.exception("Failed to save %s", path) + raise + + +# --------------------------------------------------------------------------- +# Session / cookie helpers +# --------------------------------------------------------------------------- + +def restore_auth_cookies( + session: requests.Session, + cookies_path: Path, + log: logging.Logger, +) -> None: + """Load previously saved cookies from *cookies_path* into *session*.""" + if not cookies_path.exists(): + return + try: + cached = json.loads(cookies_path.read_text(encoding="utf-8")) + if isinstance(cached, dict): + session.cookies.update(cached) + except Exception as exc: + log.warning("Saved auth cookies could not be restored: %s", exc) + + +def get_cookie_value(session: requests.Session, name: str) -> str: + """Return the value of the first cookie named *name*, or empty string.""" + for cookie in session.cookies: + if cookie.name == name: + return cookie.value + return "" + + +def get_nfvdid(session: requests.Session, response: Optional[requests.Response] = None) -> str: + """Extract the ``nfvdid`` cookie from *session* (or *response* as fallback). + + Raises ``RuntimeError`` when the cookie cannot be found. + """ + nfvdid = "" + for cookie in session.cookies: + if cookie.name == "nfvdid": + nfvdid = cookie.value + break + if not nfvdid and response is not None: + nfvdid = response.cookies.get("nfvdid", "") + if not nfvdid: + raise RuntimeError("The initial nfvdid cookie was not returned") + return nfvdid + + +def get_flow_session_cookies(session: requests.Session) -> Tuple[str, str]: + """Return ``(nfvdid, flowSessionId)`` from the session cookie jar.""" + nfvdid = "" + flow_session_id = "" + for cookie in session.cookies: + if cookie.name == "nfvdid": + nfvdid = cookie.value + elif cookie.name == "flwssn": + flow_session_id = cookie.value + return nfvdid, flow_session_id + + +def save_session_cookies( + session: requests.Session, + path: Path, + log: logging.Logger, +) -> Dict[str, str]: + """Save all session cookies to *path* as JSON and return the dict.""" + auth_cookies: Dict[str, str] = {} + for cookie in session.cookies: + auth_cookies[cookie.name] = cookie.value + try: + path.write_text(json.dumps(auth_cookies, indent=2), encoding="utf-8") + log.info("Authentication cookies saved to: %s", path) + except Exception: + log.exception("Failed to save cookies") + raise + return auth_cookies + + +def build_cookie_header( + session: requests.Session, + important_names: Tuple[str, ...], +) -> str: + """Build a ``Cookie`` header string from *important_names* cookies in *session*. + + Only the first occurrence of each name is included. + """ + seen: Dict[str, str] = {} + for cookie in session.cookies: + if cookie.name in important_names and cookie.value and cookie.name not in seen: + seen[cookie.name] = cookie.value + return "; ".join(f"{n}={seen[n]}" for n in important_names if n in seen) + + +def apply_set_cookie_headers( + session: requests.Session, + response: requests.Response, + cookie_values: Optional[List[str]] = None, +) -> None: + """Parse ``Set-Cookie`` headers from *response* and apply them to *session*. + + *cookie_values* is an optional list that will also be processed (useful + when the caller already extracted raw ``Set-Cookie`` strings from + ``response.raw.headers``). + """ + values: List[str] = list(cookie_values) if cookie_values else [] + + raw_headers = getattr(response.raw, "headers", None) + if raw_headers is not None and hasattr(raw_headers, "get_all"): + values.extend(raw_headers.get_all("Set-Cookie") or []) + header_value = response.headers.get("Set-Cookie") + if header_value and header_value not in values: + values.append(header_value) + + for raw_cookie in values: + jar = SimpleCookie() + try: + jar.load(raw_cookie) + except Exception: + continue + for morsel in jar.values(): + cookie_domain = morsel["domain"] or None + cookie_path = morsel["path"] or "/" + if morsel.value == "": + try: + session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) + except Exception: + pass + continue + try: + session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key) + except Exception: + pass + session.cookies.set( + morsel.key, morsel.value, + domain=cookie_domain, path=cookie_path, + secure=bool(morsel["secure"]), + ) + + +def dedupe_important_cookies( + session: requests.Session, + important_names: Tuple[str, ...], +) -> None: + """Remove duplicate cookies so only the best-scoring one per name remains. + + Scoring prefers ``.netflix.com`` domain, ``/`` path, and non-empty value. + """ + preferred: Dict[str, Any] = {} + for cookie in session.cookies: + if cookie.name not in important_names: + continue + current = preferred.get(cookie.name) + score = (cookie.domain == ".netflix.com", cookie.path == "/", bool(cookie.value)) + if current is None or score >= current[0]: + preferred[cookie.name] = (score, cookie) + + for cookie in list(session.cookies): + winner = preferred.get(cookie.name) + if not winner: + continue + winner_cookie = winner[1] + if (cookie.domain, cookie.path, cookie.value) != ( + winner_cookie.domain, winner_cookie.path, winner_cookie.value, + ): + try: + session.cookies.clear(domain=cookie.domain, path=cookie.path, name=cookie.name) + except Exception: + pass + + +def collect_important_cookies( + session: requests.Session, + important_names: Tuple[str, ...], +) -> Dict[str, str]: + """Return a flat dict of important cookie name → value (first seen wins).""" + cookies: Dict[str, str] = {} + for cookie in session.cookies: + if cookie.name in important_names and cookie.value and cookie.name not in cookies: + cookies[cookie.name] = cookie.value + return cookies + + +# --------------------------------------------------------------------------- +# ID / random-string generators +# --------------------------------------------------------------------------- + +def generate_hex_id(length: int = 32, uppercase: bool = False) -> str: + """Return a random hex string of the given *length*.""" + charset = "0123456789ABCDEF" if uppercase else "0123456789abcdef" + return "".join(random.choice(charset) for _ in range(length)) + + +def generate_netflix_uuid() -> str: + """Return a UUID in the Netflix ``8-4-4-4-12`` hex format.""" + return ( + generate_hex_id(8) + "-" + + generate_hex_id(4) + "-" + + generate_hex_id(4) + "-" + + generate_hex_id(4) + "-" + + generate_hex_id(12) + ) + + +def generate_request_id() -> str: + """Shorthand for a 32-char lowercase hex request ID.""" + return generate_hex_id(32) + + +def generate_esn_random_suffix(length: int = 64) -> str: + """Return a random alphanumeric suffix for ESN generation.""" + return "".join(random.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(length)) + + +# --------------------------------------------------------------------------- +# MSL crypto helpers +# --------------------------------------------------------------------------- + +def _resolve_key(raw_key: Any) -> bytes: + """Convert a key value (hex-str, bytes, or raw) to ``bytes``.""" + if isinstance(raw_key, str): + try: + return bytes.fromhex(raw_key) + except ValueError: + return raw_key.encode("utf-8") + return raw_key + + +def decrypt_msl_header( + headerdata_b64: str, + encryption_key_value: Any, + sign_key_value: Any, +) -> Dict[str, Any]: + """Decrypt an MSL encrypted header and return the parsed JSON dict. + + *encryption_key_value* and *sign_key_value* may be hex-strings or bytes. + Raises ``RuntimeError`` when either key is missing. + """ + encryption_key = _resolve_key(encryption_key_value) + sign_key = _resolve_key(sign_key_value) + + if not encryption_key: + raise RuntimeError("The encryption key is missing") + if not sign_key: + raise RuntimeError("The sign key is missing") + + encrypted_header = json.loads(base64.b64decode(headerdata_b64)) + iv = base64.b64decode(encrypted_header["iv"]) + ciphertext = base64.b64decode(encrypted_header["ciphertext"]) + + cipher = AES.new(encryption_key, AES.MODE_CBC, iv) + decrypted = unpad(cipher.decrypt(ciphertext), AES.block_size) + return json.loads(decrypted.decode("utf-8")) + + +# --------------------------------------------------------------------------- +# HTML / response parsing helpers +# --------------------------------------------------------------------------- + +def extract_first_match(html: str, patterns: List[str]) -> Optional[str]: + """Try each regex *pattern* against *html* and return the first captured group.""" + for pattern in patterns: + match = re.search(pattern, html) + if match: + return match.group(1) + return None + + +CLCS_SESSION_ID_PATTERNS = [ + r'"clcsSessionId"\s*:\s*"([0-9a-f\-]{36})"', + r'\\"clcsSessionId\\"\s*:\s*\\"([0-9a-f\-]{36})\\"', + r'"serverState"\s*:\s*"[^\"]*clcsSessionId\\":\\"([0-9a-f\-]{36})', + r'"trackingInfo"\s*:\s*"[^\"]*clcsSessionId\\":\\"([0-9a-f\-]{36})', + r'"sessionId"\s*:\s*"([0-9a-f\-]{36})"', +] + +RENDITION_ID_PATTERNS = [ + r'"renditionId"\s*:\s*"([0-9a-f\-]{36})"', + r'\\"renditionId\\"\s*:\s*\\"([0-9a-f\-]{36})\\"', +] + + +def extract_clcs_session_id(html: str) -> str: + """Extract ``clcsSessionId`` from login page HTML. + + Raises ``RuntimeError`` when not found. + """ + result = extract_first_match(html, CLCS_SESSION_ID_PATTERNS) + if not result: + raise RuntimeError("Could not extract clcsSessionId from the login page HTML") + return result + + +def extract_rendition_id(html: str) -> str: + """Extract ``renditionId`` from login page HTML. + + Raises ``RuntimeError`` when not found. + """ + result = extract_first_match(html, RENDITION_ID_PATTERNS) + if not result: + raise RuntimeError("Could not extract the initial renditionId from the login page HTML") + return result + + +def parse_flow_data(response_data: Dict[str, Any]) -> Dict[str, str]: + """Walk a GraphQL response tree and extract flow metadata. + + Returns a dict that may contain keys: + ``clcsSessionId``, ``renditionId``, ``flowSessionId``, ``mode``, + ``flow``, ``membershipStatus``. + """ + flow: Dict[str, str] = {} + data = response_data.get("data", {}) + operation_key = next(iter(data.keys()), "") + inner = data.get(operation_key, {}) + screen = inner.get("screen", inner) if isinstance(inner, dict) else {} + + stack = [screen] + while stack: + value = stack.pop() + if isinstance(value, dict): + tracking_info = value.get("trackingInfo") + if isinstance(tracking_info, str) and tracking_info: + try: + tracking = json.loads(tracking_info) + except Exception: + tracking = {} + if tracking.get("clcsSessionId") and not flow.get("clcsSessionId"): + flow["clcsSessionId"] = tracking.get("clcsSessionId", "") + if tracking.get("clcsRenditionId") and not flow.get("renditionId"): + flow["renditionId"] = tracking.get("clcsRenditionId", "") + payload_json = value.get("payloadJson") + if isinstance(payload_json, str) and payload_json: + try: + payload = json.loads(payload_json) + except Exception: + payload = {} + if payload.get("flwssn") and not flow.get("flowSessionId"): + flow["flowSessionId"] = payload.get("flwssn", "") + if payload.get("mode") and not flow.get("mode"): + flow["mode"] = payload.get("mode", "") + if payload.get("flow") and not flow.get("flow"): + flow["flow"] = payload.get("flow", "") + if value.get("membershipStatus"): + flow["membershipStatus"] = value.get("membershipStatus", "") + for child in value.values(): + stack.append(child) + elif isinstance(value, list): + for item in value: + stack.append(item) + + return flow + + +# --------------------------------------------------------------------------- +# MSL payload parsing helpers (used by run_tv / run_tv_otp) +# --------------------------------------------------------------------------- + +def parse_msl_payload(payload: Any) -> Tuple[str, Optional[Dict], Optional[str]]: + """Classify an MSL payload and return ``(type_str, parsed_dict, text_str)``. + + *type_str* is one of ``"msl_payload"``, ``"json_array"``, or ``"text"``. + """ + payload_type = type(payload).__name__ + parsed_payload: Optional[Dict] = None + text_payload: Optional[str] = None + + if isinstance(payload, dict): + payload_type = "msl_payload" + parsed_payload = payload + elif isinstance(payload, list): + payload_type = "json_array" + parsed_payload = {"items": payload} + elif isinstance(payload, str): + cleaned = payload.rstrip( + "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10" + ) + text_payload = cleaned + try: + maybe_json = json.loads(cleaned) + if isinstance(maybe_json, dict): + parsed_payload = maybe_json + payload_type = "text" + else: + payload_type = "text" + except Exception: + payload_type = "text" + + return payload_type, parsed_payload, text_payload + + +def extract_useridtoken_from_payload( + parsed_payload: Optional[Dict], raw_payload: Any, +) -> Optional[Dict[str, Any]]: + """Walk the payload tree and return the first dict with both + ``tokendata`` and ``signature`` keys (the useridtoken).""" + useridtoken: Optional[Dict[str, Any]] = None + stack = [parsed_payload if parsed_payload is not None else raw_payload] + while stack: + current = stack.pop() + if isinstance(current, dict): + if useridtoken is None and set(current.keys()) >= {"tokendata", "signature"}: + useridtoken = current + for child in current.values(): + stack.append(child) + elif isinstance(current, list): + for item in current: + stack.append(item) + return useridtoken + + +def build_msl_trace_event( + msl_message_id: Any, + key_id: str, + payload_type: str, + parsed_payload: Optional[Dict], + text_payload: Optional[str], + extra_fields: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Build a single MSL trace event dict (used for debug logging).""" + from datetime import datetime, timezone + + event: Dict[str, Any] = { + "_type": "decrypt", + "_mslId": msl_message_id, + "_timestamp": datetime.now(timezone.utc) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z"), + "_keyId": key_id, + "_dataType": payload_type, + } + if extra_fields: + event.update(extra_fields) + if text_payload is not None: + event["_text"] = text_payload + if parsed_payload is not None: + event["_payload"] = parsed_payload + return event + + +def extract_key_id_from_mastertoken(mastertoken: Dict[str, Any]) -> str: + """Extract the hex key ID from a master token's ``tokendata``.""" + if not mastertoken: + return "" + token_data = json.loads(base64.b64decode(mastertoken["tokendata"]).decode("utf-8")) + return str(token_data.get("sequencenumber", "")).encode("utf-8").hex() + + +# --------------------------------------------------------------------------- +# Request-args helpers (TV platform) +# --------------------------------------------------------------------------- + +def request_args_to_dict(request_args: List[Dict[str, Any]]) -> Dict[str, Any]: + """Convert a list of ``{"name":…, "value": {"stringValue"|"booleanValue":…}}`` + dicts into a plain ``{name: value}`` mapping.""" + result: Dict[str, Any] = {} + for arg in request_args: + value = arg["value"] + if "stringValue" in value: + result[arg["name"]] = value["stringValue"] + elif "booleanValue" in value: + result[arg["name"]] = value["booleanValue"] + return result diff --git a/modules/msl_android.py b/modules/msl_android.py index 8fac929..3d2dc8f 100644 --- a/modules/msl_android.py +++ b/modules/msl_android.py @@ -1,28 +1,29 @@ +from __future__ import annotations + import base64 -import gzip import json import random -import sys -import zlib +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + import jsonpickle import requests -from io import BytesIO -from pathlib import Path -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional, Tuple -from Cryptodome.Cipher import AES -from Cryptodome.Hash import HMAC, SHA256 +from Cryptodome.Cipher import PKCS1_OAEP, AES as _AES +from Cryptodome.Hash import SHA1 +from Cryptodome.PublicKey import RSA from Cryptodome.PublicKey.RSA import RsaKey -from Cryptodome.Random import get_random_bytes -from Cryptodome.Util import Padding from pywidevine import Cdm as WidevineCdm, Device as WidevineDevice, PSSH -class MSLObject: - def __repr__(self) -> str: - return f"<{self.__class__.__name__} {jsonpickle.encode(self, unpicklable=False)}>" +from modules.msl_base import MSLBase, MSLKeys as _BaseMSLKeys, get_widevine_key -class MSLKeys(MSLObject): +# --------------------------------------------------------------------------- +# Platform-specific key container +# --------------------------------------------------------------------------- + +class MSLKeys(_BaseMSLKeys): + """Android MSL keys – extends the base with an RSA key and CDM session.""" + def __init__( self, encryption: Optional[bytes] = None, @@ -30,26 +31,40 @@ class MSLKeys(MSLObject): rsa: Optional[RsaKey] = None, mastertoken: Optional[dict] = None, cdm_session: Any = None, - ): - self.encryption = encryption - self.sign = sign + ) -> None: + super().__init__(encryption=encryption, sign=sign, mastertoken=mastertoken) self.rsa = rsa - self.mastertoken = mastertoken self.cdm_session = cdm_session -class MSL_ANDROID: - DEFAULT_HANDSHAKE_ENDPOINT = "https://android.prod.ftl.netflix.com/nq/androidui/pbo_license/~1.0.0/router" - DEFAULT_MANIFEST_ENDPOINT = "https://android.prod.ftl.netflix.com/msl/playapi/android/manifest" - DEFAULT_MANIFEST_PARAMS = { + +# --------------------------------------------------------------------------- +# MSL_ANDROID +# --------------------------------------------------------------------------- + +class MSL_ANDROID(MSLBase): + """Netflix MSL client for Android devices using Widevine DRM.""" + + # -- Platform constants -------------------------------------------------- + DEFAULT_HANDSHAKE_ENDPOINT: str = ( + "https://android.prod.ftl.netflix.com/nq/androidui/pbo_license/~1.0.0/router" + ) + DEFAULT_MANIFEST_ENDPOINT: str = ( + "https://android.prod.ftl.netflix.com/msl/playapi/android/manifest" + ) + DEFAULT_MANIFEST_PARAMS: Dict[str, str] = { "ab_ui_ver": "android", "nrdapp_version": "18.26.0", } - DEFAULT_USER_AGENT = "com.netflix.mediaclient/63988 (Linux; U; Android 15; en_US; SM-F711N; Build/AP3A.240905.015.A2; Cronet/143.0.7445.0)" - DEFAULT_REQUEST_CONTEXT = '{"appState":"foreground","appView":"unknown"}' - DEFAULT_NRDJS_VERSION = "v3.12.55" - DEFAULT_NETJS_VERSION = "3.0.5" - DEFAULT_PBO_VERSION = 2 - DEFAULT_PBO_COMMON = { + DEFAULT_USER_AGENT: str = ( + "com.netflix.mediaclient/63988 " + "(Linux; U; Android 15; en_US; SM-F711N; " + "Build/AP3A.240905.015.A2; Cronet/143.0.7445.0)" + ) + DEFAULT_REQUEST_CONTEXT: str = '{"appState":"foreground","appView":"unknown"}' + DEFAULT_NRDJS_VERSION: str = "v3.12.55" + DEFAULT_NETJS_VERSION: str = "3.0.5" + DEFAULT_PBO_VERSION: int = 2 + DEFAULT_PBO_COMMON: Dict[str, str] = { "sdk": "18.26.0", "platform": "18.26.0", "application": "Netflix Android 18.26.0", @@ -58,8 +73,10 @@ class MSL_ANDROID: "clientVersion": "18.26.0", "apkVersion": "18.26.0", } - DEFAULT_PBO_LANGUAGES = ["en-US", "en"] - DEFAULT_DEVICE_MODEL = "SM-F711N" + DEFAULT_PBO_LANGUAGES: List[str] = ["en-US", "en"] + DEFAULT_DEVICE_MODEL: str = "SM-F711N" + + # -- Constructor --------------------------------------------------------- def __init__( self, @@ -69,14 +86,13 @@ class MSL_ANDROID: sender: str, user_auth: Optional[dict] = None, drm: str = "widevine", - ): - self.session = session - self.keys = keys - self.sender = sender + ) -> None: + super().__init__(session=session, keys=keys, message_id=message_id, sender=sender) self.user_auth = user_auth - self.message_id = message_id self.drm = drm + # -- Widevine handshake -------------------------------------------------- + @classmethod def handshake( cls, @@ -91,11 +107,12 @@ class MSL_ANDROID: endpoint: Optional[str] = None, headers: Optional[Dict[str, str]] = None, ) -> MSLKeys: + """Perform a Widevine key exchange and return negotiated keys.""" if cookies: session.cookies.update(cookies) cache_path = Path(msl_keys_path) - msl_keys = MSL_ANDROID.load_cache_data(cache_path) + msl_keys = cls.load_cache_data(cache_path) if msl_keys is not None and not new_msl: return msl_keys @@ -103,7 +120,7 @@ class MSL_ANDROID: raise ValueError(f"Unsupported DRM mode: {drm}") if not cdm: - raise ValueError("Widevine CDM is required for this iOS MSL flow") + raise ValueError("Widevine CDM is required for this Android MSL flow") message_id = random.randint(0, pow(2, 52)) msl_keys = MSLKeys() @@ -135,7 +152,7 @@ class MSL_ANDROID: }, }, "headerdata": base64.standard_b64encode( - MSL_ANDROID.generate_msg_header( + cls.generate_msg_header( message_id=message_id, sender=sender, is_handshake=True, @@ -169,10 +186,14 @@ class MSL_ANDROID: host="android15.prod.cloud.netflix.com", language="en-US,en", ) - res = session.post(url=handshake_endpoint, data=data, headers=handshake_headers, timeout=30) + res = session.post( + url=handshake_endpoint, data=data, headers=handshake_headers, timeout=30 + ) if res.status_code != 200: - raise RuntimeError(f"Key exchange failed: HTTP {res.status_code} {res.text[:500]}") + raise RuntimeError( + f"Key exchange failed: HTTP {res.status_code} {res.text[:500]}" + ) parsed = cls.parse_concatenated_json(res.text) if not parsed: @@ -181,12 +202,17 @@ class MSL_ANDROID: key_exchange = parsed[0] if "errordata" in key_exchange: - decoded_error = base64.standard_b64decode(key_exchange["errordata"]).decode("utf-8") + decoded_error = base64.standard_b64decode( + key_exchange["errordata"] + ).decode("utf-8") error_json = json.loads(decoded_error) raise RuntimeError(f"Key exchange failed: {error_json}") if "headerdata" not in key_exchange: - raise RuntimeError(f"Key exchange failed: missing headerdata in response: {str(key_exchange)[:500]}") + raise RuntimeError( + f"Key exchange failed: missing headerdata in response: " + f"{str(key_exchange)[:500]}" + ) header_json = json.loads( base64.standard_b64decode(key_exchange["headerdata"]).decode("utf-8") @@ -195,22 +221,190 @@ class MSL_ANDROID: key_data = key_response_data["keydata"] cdm.parse_license(msl_keys.cdm_session, key_data["cdmkeyresponse"]) - keys = cdm.get_keys(msl_keys.cdm_session) - msl_keys.encryption = MSL_ANDROID.get_widevine_key( + wv_keys = cdm.get_keys(msl_keys.cdm_session) + msl_keys.encryption = get_widevine_key( kid=base64.standard_b64decode(key_data["encryptionkeyid"]), - keys=keys, + keys=wv_keys, permissions=["AllowEncrypt", "AllowDecrypt"], ) - msl_keys.sign = MSL_ANDROID.get_widevine_key( + msl_keys.sign = get_widevine_key( kid=base64.standard_b64decode(key_data["hmackeyid"]), - keys=keys, + keys=wv_keys, permissions=["AllowSign", "AllowSignatureVerify"], ) msl_keys.mastertoken = key_response_data["mastertoken"] - MSL_ANDROID.cache_keys(msl_keys, cache_path) + cls.cache_keys(msl_keys, cache_path) return msl_keys + # -- RSA / ASYMMETRIC_WRAPPED handshake (no Widevine required) ----------- + + @classmethod + def rsa_handshake( + cls, + msl_keys_path: str, + session: requests.Session, + sender: str, + new_msl: bool, + cookies: Optional[Dict[str, str]], + endpoint: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + ) -> "MSLKeys": + """Perform an ASYMMETRIC_WRAPPED (RSA + JWE) key exchange. + + This path does **not** require a Widevine device file. Netflix wraps + the session AES-GCM keys with our ephemeral RSA-OAEP public key and + returns them inside a JWE compact serialisation. + + The ESN must use the ``NFCDCH-02-`` prefix (web-style) so that the + Android FTL endpoint accepts the ``NONE`` entity auth scheme. + """ + if cookies: + session.cookies.update(cookies) + + cache_path = Path(msl_keys_path) + cached = cls.load_cache_data(cache_path) + if cached is not None and not new_msl: + return cached + + # ---- Generate ephemeral RSA-2048 keypair ---------------------------- + rsa_key = RSA.generate(2048) + pub_der_b64 = base64.b64encode( + rsa_key.publickey().export_key("DER") + ).decode("ascii") + + message_id = random.randint(0, 2 ** 52) + msl_keys = MSLKeys(rsa=rsa_key) + + keyrequestdata = { + "scheme": "ASYMMETRIC_WRAPPED", + "keydata": { + "keypairid": "rsaKeypairId", + "mechanism": "JWE_RSA", + "publickey": pub_der_b64, + }, + } + + data = jsonpickle.encode( + { + "entityauthdata": { + "scheme": "NONE", + "authdata": {"identity": sender}, + }, + "headerdata": base64.standard_b64encode( + cls.generate_msg_header( + message_id=message_id, + sender=sender, + is_handshake=True, + keyrequestdata=keyrequestdata, + ).encode("utf-8") + ).decode("utf-8"), + "signature": "", + }, + unpicklable=False, + ) + data += json.dumps( + { + "payload": base64.standard_b64encode( + json.dumps( + { + "messageid": message_id, + "data": "", + "sequencenumber": 1, + "endofmsg": True, + } + ).encode("utf-8") + ).decode("utf-8"), + "signature": "", + } + ) + + handshake_endpoint = endpoint or cls.DEFAULT_HANDSHAKE_ENDPOINT + handshake_headers = headers or cls.build_request_headers( + request_name="mintCookies", + esn=sender, + host="android15.prod.cloud.netflix.com", + language="en-US,en", + ) + res = session.post( + url=handshake_endpoint, + data=data, + headers=handshake_headers, + timeout=30, + ) + + if res.status_code != 200: + raise RuntimeError( + f"RSA key exchange failed: HTTP {res.status_code} {res.text[:500]}" + ) + + parsed = cls.parse_concatenated_json(res.text) + if not parsed: + raise RuntimeError("RSA key exchange failed: empty MSL response") + + key_exchange = parsed[0] + + if "errordata" in key_exchange: + decoded_error = base64.standard_b64decode( + key_exchange["errordata"] + ).decode("utf-8") + raise RuntimeError( + f"RSA key exchange failed: {json.loads(decoded_error)}" + ) + + if "headerdata" not in key_exchange: + raise RuntimeError( + f"RSA key exchange failed: missing headerdata in response: " + f"{str(key_exchange)[:500]}" + ) + + header_json = json.loads( + base64.standard_b64decode(key_exchange["headerdata"]).decode("utf-8") + ) + key_response_data = header_json["keyresponsedata"] + key_data = key_response_data["keydata"] + + # ---- Decrypt JWE-wrapped session keys ------------------------------- + msl_keys.encryption = cls._decrypt_jwe_key( + key_data["encryptionkey"], rsa_key + ) + msl_keys.sign = cls._decrypt_jwe_key(key_data["hmackey"], rsa_key) + msl_keys.mastertoken = key_response_data["mastertoken"] + + # Don't persist the RSA key object (not picklable); clear it before caching + msl_keys.rsa = None + cls.cache_keys(msl_keys, cache_path) + return msl_keys + + @staticmethod + def _decrypt_jwe_key(field_b64: str, rsa_private: RsaKey) -> bytes: + """Decrypt a Netflix JWE-wrapped session key. + + The field value is standard-base64-encoded JWE compact serialisation. + Netflix uses RSA-OAEP (SHA-1) for the CEK and A128GCM for content + but skips the AAD in the GCM tag — so we decrypt without verification + and trust the RSA layer for integrity. + """ + jwe_compact = base64.b64decode(field_b64 + "==").decode("utf-8") + hdr_b64, enc_cek_b64, iv_b64, ct_b64, _ = jwe_compact.split(".") + + # Decrypt the Content Encryption Key (CEK) with RSA-OAEP / SHA-1 + enc_cek = base64.urlsafe_b64decode(enc_cek_b64 + "==") + cek = PKCS1_OAEP.new(rsa_private, hashAlgo=SHA1).decrypt(enc_cek) + + # Decrypt the payload with AES-128-GCM (no AAD verification) + iv = base64.urlsafe_b64decode(iv_b64 + "==") + ct = base64.urlsafe_b64decode(ct_b64 + "==") + plaintext = _AES.new(cek, _AES.MODE_GCM, nonce=iv).decrypt(ct) + + # Payload is a JWK JSON — extract the raw key bytes + jwk = json.loads(plaintext.decode("utf-8")) + remainder = len(jwk["k"]) % 4 + padded = jwk["k"] + "=" * (4 - remainder if remainder else 0) + return base64.urlsafe_b64decode(padded) + + # -- Platform-specific request headers ----------------------------------- + @staticmethod def build_request_headers( request_name: str, @@ -225,6 +419,7 @@ class MSL_ANDROID: language: Optional[str] = "en-US", device_model: Optional[str] = None, ) -> Dict[str, str]: + """Build the HTTP headers required for Android MSL requests.""" headers: Dict[str, str] = { "Host": host or "android15.prod.cloud.netflix.com", "Accept": "*/*", @@ -237,7 +432,9 @@ class MSL_ANDROID: "X-DeviceModel": device_model or MSL_ANDROID.DEFAULT_DEVICE_MODEL, "x-netflix.client.request.name": request_name, "x-netflix.request.attempt": "1", - "x-netflix.request.id": "".join(random.choice("0123456789abcdef") for _ in range(32)), + "x-netflix.request.id": "".join( + random.choice("0123456789abcdef") for _ in range(32) + ), "x-netflix.request.client.context": MSL_ANDROID.DEFAULT_REQUEST_CONTEXT, "x-netflix.request.client.languages": "en-US", "x-netflix.request.client.timezoneid": "America/New_York", @@ -273,50 +470,14 @@ class MSL_ANDROID: headers.update(extra_headers) return headers + # -- Manifest defaults --------------------------------------------------- + @staticmethod def manifest_request_defaults() -> Tuple[str, Dict[str, str]]: + """Return the default manifest endpoint and query params for Android.""" return MSL_ANDROID.DEFAULT_MANIFEST_ENDPOINT, dict(MSL_ANDROID.DEFAULT_MANIFEST_PARAMS) - @staticmethod - def generate_msg_header( - message_id: int, - sender: str, - is_handshake: bool, - userauthdata: Optional[dict] = None, - keyrequestdata: Optional[dict] = None, - compression: Optional[str] = "GZIP", - ) -> str: - header_data: Dict[str, Any] = { - "messageid": message_id, - "renewable": True, - "handshake": is_handshake, - "capabilities": { - "compressionalgos": [compression] if compression else [], - "languages": ["en-US", "en"], - "encoderformats": ["JSON"], - }, - "timestamp": int(datetime.now(timezone.utc).timestamp()), - "sender": sender, - "nonreplayable": False, - "recipient": "Netflix", - } - if userauthdata: - header_data["userauthdata"] = userauthdata - if keyrequestdata: - header_data["keyrequestdata"] = [keyrequestdata] - return jsonpickle.encode(header_data, unpicklable=False) - - @staticmethod - def get_widevine_key(kid: bytes, keys: List[Any], permissions: List[str]) -> Optional[bytes]: - import re - normalized_perms = {re.sub(r'(? Tuple[Dict[str, Any], Any]: - normalized_application_data = self.normalize_application_data(endpoint, application_data) - message = self.create_message(normalized_application_data, userauthdata) - request_kwargs: Dict[str, Any] = { - "url": endpoint, - "data": message, - "params": params, - "headers": headers, - "timeout": 30, - } - if proxy: - request_kwargs["proxies"] = proxy - - res = self.session.post(**request_kwargs) - - if res.status_code != 200: - raise RuntimeError( - f"MSL request failed with HTTP {res.status_code}: {res.text[:500]}" - ) - - response_text = res.text or "" - stripped_response = response_text.lstrip() - if not stripped_response: - raise RuntimeError("MSL request failed: empty response body") - - if not stripped_response.startswith("{"): - content_type = res.headers.get("content-type", "") - raise RuntimeError( - "MSL request failed: the server did not return concatenated MSL JSON. " - f"Content-Type: {content_type!r}. Body preview: {response_text[:500]!r}" - ) - - header, payload_data = self.parse_message(response_text) - if not header: - raise RuntimeError( - f"MSL request failed: parsed response does not contain a header. Body preview: {response_text[:500]!r}" - ) - - if "errordata" in header: - decoded_error = json.loads( - base64.standard_b64decode(header["errordata"].encode("utf-8")).decode("utf-8") - ) - raise RuntimeError(f"MSL response contains an error: {decoded_error}") - - return header, payload_data - - @classmethod - def normalize_application_data(cls, endpoint: str, application_data: Any) -> Any: - if not isinstance(application_data, dict): - return application_data - - if cls._looks_like_wrapped_pbo_payload(application_data): - return application_data - - route = cls._extract_pbo_route(application_data, endpoint) - if route is None: - return application_data - - common = dict(cls.DEFAULT_PBO_COMMON) - if isinstance(application_data.get("common"), dict): - common.update(application_data["common"]) - - wrapped: Dict[str, Any] = { - "version": application_data.get("version", cls.DEFAULT_PBO_VERSION), - "common": common, - "url": route, - "languages": application_data.get("languages", list(cls.DEFAULT_PBO_LANGUAGES)), - "params": application_data.get("params", {}), - } - - for key in ("path", "method", "route", "endpoint"): - wrapped.pop(key, None) - - for key, value in application_data.items(): - if key in wrapped or key in {"version", "common", "languages", "params", "path", "method", "route", "endpoint"}: - continue - wrapped[key] = value - return wrapped - - @staticmethod - def _looks_like_wrapped_pbo_payload(application_data: Dict[str, Any]) -> bool: - return ( - "version" in application_data - and "common" in application_data - and "url" in application_data - and "languages" in application_data - and "params" in application_data + """Send an MSL message with PBO payload normalisation.""" + normalized = self.normalize_application_data(endpoint, application_data) + return super().send_message( + endpoint=endpoint, + params=params, + application_data=normalized, + userauthdata=userauthdata, + headers=headers, + proxy=proxy, + timeout=timeout, ) - @staticmethod - def _extract_pbo_route(application_data: Dict[str, Any], endpoint: str) -> Optional[str]: - for key in ("url", "path", "route", "method", "endpoint"): - value = application_data.get(key) - if not isinstance(value, str) or not value.strip(): - continue - value = value.strip() - return value if value.startswith("/") else f"/{value}" - endpoint_lower = endpoint.lower() - if "manifest" in endpoint_lower: - return "/manifest" - if "pbo_tokens" in endpoint_lower or "pbo_config" in endpoint_lower: - return None - return None - - def create_message(self, application_data: Dict[str, Any], userauthdata: Optional[dict] = None) -> str: - self.message_id += 1 - - headerdata = self.encrypt( - self.generate_msg_header( - message_id=self.message_id, - sender=self.sender, - is_handshake=False, - userauthdata=userauthdata, - ) - ) - - message = json.dumps( - { - "headerdata": base64.standard_b64encode(headerdata.encode("utf-8")).decode("utf-8"), - "signature": self.sign(headerdata).decode("utf-8"), - "mastertoken": self.keys.mastertoken, - }, - separators=(",", ":"), - ) - - compressed_application_data = self.gzip_compress( - json.dumps(application_data, separators=(",", ":")).encode("utf-8") - ).decode("utf-8") - payload_dicts = [ - { - "sequencenumber": 1, - "messageid": self.message_id, - "compressionalgo": "GZIP", - "data": compressed_application_data, - }, - { - "sequencenumber": 2, - "messageid": self.message_id, - "endofmsg": True, - "data": "", - }, - ] - - for payload_dict in payload_dicts: - payload_chunk = self.encrypt(json.dumps(payload_dict, separators=(",", ":"))) - message += json.dumps( - { - "payload": base64.standard_b64encode(payload_chunk.encode("utf-8")).decode("utf-8"), - "signature": self.sign(payload_chunk).decode("utf-8"), - }, - separators=(",", ":"), - ) - return message - - def decrypt_payload_chunks(self, payload_chunks: List[Dict[str, str]]) -> Any: - raw_data = "" - assert self.keys.encryption is not None - - for payload_chunk in payload_chunks: - payload_chunk_json = json.loads(base64.standard_b64decode(payload_chunk["payload"]).decode("utf-8")) - payload_decrypted = AES.new( - key=self.keys.encryption, - mode=AES.MODE_CBC, - iv=base64.standard_b64decode(payload_chunk_json["iv"]), - ).decrypt(base64.standard_b64decode(payload_chunk_json["ciphertext"])) - payload_decrypted = Padding.unpad(payload_decrypted, 16) - payload_decrypted_json = json.loads(payload_decrypted.decode("utf-8")) - - payload_data = base64.standard_b64decode(payload_decrypted_json["data"]) - if payload_decrypted_json.get("compressionalgo") == "GZIP": - payload_data = zlib.decompress(payload_data, 16 + zlib.MAX_WBITS) - raw_data += payload_data.decode("utf-8") - - if not raw_data: - return None - - try: - data = json.loads(raw_data) - except Exception: - return raw_data - - if "error" in data: - return None - if "result" not in data: - return data - return data["result"] - - @staticmethod - def parse_concatenated_json(message: str) -> List[Dict[str, Any]]: - decoder = json.JSONDecoder() - items: List[Dict[str, Any]] = [] - index = 0 - length = len(message) - - while index < length: - while index < length and message[index].isspace(): - index += 1 - if index >= length: - break - item, next_index = decoder.raw_decode(message, index) - items.append(item) - index = next_index - return items - - def parse_message(self, message: str) -> Tuple[Dict[str, Any], Any]: - parsed_message = self.parse_concatenated_json(message) - header = parsed_message[0] - encrypted_payload_chunks = parsed_message[1:] if len(parsed_message) > 1 else [] - payload_chunks = self.decrypt_payload_chunks(encrypted_payload_chunks) if encrypted_payload_chunks else {} - return header, payload_chunks - - @staticmethod - def gzip_compress(data: bytes) -> bytes: - out = BytesIO() - with gzip.GzipFile(fileobj=out, mode="w") as gzip_file: - gzip_file.write(data) - return base64.standard_b64encode(out.getvalue()) - - @staticmethod - def base64key_decode(payload: str) -> bytes: - length = len(payload) % 4 - if length == 2: - payload += "==" - elif length == 3: - payload += "=" - elif length != 0: - raise ValueError("Invalid base64 string") - return base64.urlsafe_b64decode(payload.encode("utf-8")) - - def encrypt(self, plaintext: str) -> str: - if not self.keys.encryption: - raise ValueError("Encryption key is not available") - if not self.keys.mastertoken: - raise ValueError("Master token is not available") - - iv = get_random_bytes(16) - tokendata = json.loads(base64.standard_b64decode(self.keys.mastertoken["tokendata"]).decode("utf-8")) - return json.dumps( - { - "ciphertext": base64.standard_b64encode( - AES.new(self.keys.encryption, AES.MODE_CBC, iv).encrypt( - Padding.pad(plaintext.encode("utf-8"), 16) - ) - ).decode("utf-8"), - "keyid": f"{self.sender}_{tokendata['sequencenumber']}", - "sha256": "AA==", - "iv": base64.standard_b64encode(iv).decode("utf-8"), - } - ) - - def sign(self, text: str) -> bytes: - if not self.keys.sign: - raise ValueError("Sign key is not available") - return base64.standard_b64encode(HMAC.new(self.keys.sign, text.encode("utf-8"), SHA256).digest()) - - @staticmethod - def load_cache_data(msl_keys_path: Optional[Path] = None) -> Optional[MSLKeys]: - if not msl_keys_path or not msl_keys_path.is_file(): - return None - - msl_keys = jsonpickle.decode(msl_keys_path.read_text(encoding="utf-8")) - if msl_keys.mastertoken: - tokendata = json.loads(base64.standard_b64decode(msl_keys.mastertoken["tokendata"]).decode("utf-8")) - renewal_window = datetime.fromtimestamp(int(tokendata["renewalwindow"]), tz=timezone.utc) - remaining_hours = (renewal_window - datetime.now(timezone.utc)).total_seconds() / 3600 - if remaining_hours < 10: - return None - return msl_keys - - @staticmethod - def cache_keys(msl_keys: MSLKeys, msl_keys_path: Path) -> None: - with open(msl_keys_path, "w", encoding="utf-8") as cache_file: - cache_file.write(jsonpickle.encode(msl_keys, indent=4)) \ No newline at end of file +__all__ = ["MSL_ANDROID", "MSLKeys"] diff --git a/modules/msl_base.py b/modules/msl_base.py new file mode 100644 index 0000000..fd9d71d --- /dev/null +++ b/modules/msl_base.py @@ -0,0 +1,502 @@ + +from __future__ import annotations + +import base64 +import gzip +import json +import random +import re +import zlib + +import jsonpickle +import requests +from io import BytesIO +from pathlib import Path +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from Cryptodome.Cipher import AES +from Cryptodome.Hash import HMAC, SHA256 +from Cryptodome.Random import get_random_bytes +from Cryptodome.Util import Padding + + +# --------------------------------------------------------------------------- +# Core data objects +# --------------------------------------------------------------------------- + +class MSLObject: + """Base class providing a compact JSON repr for debugging.""" + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {jsonpickle.encode(self, unpicklable=False)}>" + + +class MSLKeys(MSLObject): + """Holds the negotiated encryption / signing keys and master token.""" + + def __init__( + self, + encryption: Optional[bytes] = None, + sign: Optional[bytes] = None, + mastertoken: Optional[dict] = None, + ) -> None: + self.encryption = encryption + self.sign = sign + self.mastertoken = mastertoken + + +# --------------------------------------------------------------------------- +# Widevine key helper (shared by Android, iOS, TV) +# --------------------------------------------------------------------------- + +def get_widevine_key(kid: bytes, keys: List[Any], permissions: List[str]) -> Optional[bytes]: + """Find the Widevine operator-session key whose permissions are a + superset of *permissions*. Permission names are normalised from + CamelCase to snake_case before comparison.""" + normalized_perms = {re.sub(r'(? None: + self.session = session + self.keys = keys + self.sender = sender + self.message_id = message_id + + # ----------------------------------------------------------------------- + # JSON helpers + # ----------------------------------------------------------------------- + + @staticmethod + def parse_concatenated_json(message: str) -> List[Dict[str, Any]]: + """Parse concatenated JSON objects from a single string (MSL wire format).""" + decoder = json.JSONDecoder() + items: List[Dict[str, Any]] = [] + index = 0 + length = len(message) + while index < length: + while index < length and message[index].isspace(): + index += 1 + if index >= length: + break + item, next_index = decoder.raw_decode(message, index) + items.append(item) + index = next_index + return items + + @staticmethod + def stable_json(obj: Dict[str, Any]) -> str: + return json.dumps(obj, separators=(",", ":"), ensure_ascii=False) + + # ----------------------------------------------------------------------- + # Base64 helpers + # ----------------------------------------------------------------------- + + @staticmethod + def b64_encode_bytes(value: bytes) -> str: + return base64.b64encode(value).decode("ascii") + + @staticmethod + def base64key_decode(payload: str) -> bytes: + """Decode a URL-safe base64 JWK key, padding if necessary.""" + remainder = len(payload) % 4 + if remainder == 2: + payload += "==" + elif remainder == 3: + payload += "=" + elif remainder != 0: + raise ValueError("Invalid base64 string") + return base64.urlsafe_b64decode(payload.encode("utf-8")) + + # ----------------------------------------------------------------------- + # Compression + # ----------------------------------------------------------------------- + + @staticmethod + def gzip_compress(data: bytes) -> bytes: + out = BytesIO() + with gzip.GzipFile(fileobj=out, mode="w") as gz: + gz.write(data) + return base64.standard_b64encode(out.getvalue()) + + # ----------------------------------------------------------------------- + # Encryption / Signing + # ----------------------------------------------------------------------- + + def encrypt(self, plaintext: str) -> str: + if not self.keys.encryption: + raise ValueError("Encryption key is not available") + if not self.keys.mastertoken: + raise ValueError("Master token is not available") + + iv = get_random_bytes(16) + tokendata = json.loads( + base64.standard_b64decode(self.keys.mastertoken["tokendata"]).decode("utf-8") + ) + ciphertext = AES.new(self.keys.encryption, AES.MODE_CBC, iv).encrypt( + Padding.pad(plaintext.encode("utf-8"), 16) + ) + return json.dumps( + { + "ciphertext": base64.standard_b64encode(ciphertext).decode("utf-8"), + "keyid": f"{self.sender}_{tokendata['sequencenumber']}", + "sha256": "AA==", + "iv": base64.standard_b64encode(iv).decode("utf-8"), + }, + separators=(",", ":"), + ) + + def sign(self, text: str) -> bytes: + if not self.keys.sign: + raise ValueError("Sign key is not available") + return base64.standard_b64encode( + HMAC.new(self.keys.sign, text.encode("utf-8"), SHA256).digest() + ) + + # ----------------------------------------------------------------------- + # AES-CBC convenience (used by MGK branch) + # ----------------------------------------------------------------------- + + @staticmethod + def aes_cbc_encrypt(key: bytes, iv: bytes, plaintext: bytes) -> bytes: + return AES.new(key, AES.MODE_CBC, iv).encrypt(Padding.pad(plaintext, AES.block_size)) + + @staticmethod + def aes_cbc_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes: + return Padding.unpad( + AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext), + AES.block_size, + ) + + # ----------------------------------------------------------------------- + # Message creation + # ----------------------------------------------------------------------- + + @staticmethod + def generate_msg_header( + message_id: int, + sender: str, + is_handshake: bool, + userauthdata: Optional[dict] = None, + keyrequestdata: Optional[dict] = None, + compression: Optional[str] = "GZIP", + languages: Optional[List[str]] = None, + recipient: Optional[str] = "Netflix", + ) -> str: + header_data: Dict[str, Any] = { + "messageid": message_id, + "renewable": True, + "handshake": is_handshake, + "capabilities": { + "compressionalgos": [compression] if compression else [], + "languages": languages or ["en-US", "en"], + "encoderformats": ["JSON"], + }, + "timestamp": int(datetime.now(timezone.utc).timestamp()), + "sender": sender, + "nonreplayable": False, + } + if recipient: + header_data["recipient"] = recipient + if userauthdata: + header_data["userauthdata"] = userauthdata + if keyrequestdata: + header_data["keyrequestdata"] = [keyrequestdata] + return jsonpickle.encode(header_data, unpicklable=False) + + def create_message( + self, + application_data: Dict[str, Any], + userauthdata: Optional[dict] = None, + ) -> str: + self.message_id += 1 + + headerdata = self.encrypt( + self.generate_msg_header( + message_id=self.message_id, + sender=self.sender, + is_handshake=False, + userauthdata=userauthdata, + ) + ) + + message = json.dumps( + { + "headerdata": base64.standard_b64encode(headerdata.encode("utf-8")).decode("utf-8"), + "signature": self.sign(headerdata).decode("utf-8"), + "mastertoken": self.keys.mastertoken, + }, + separators=(",", ":"), + ) + + compressed_application_data = self.gzip_compress( + json.dumps(application_data, separators=(",", ":")).encode("utf-8") + ).decode("utf-8") + + payload_dicts = [ + { + "sequencenumber": 1, + "messageid": self.message_id, + "compressionalgo": "GZIP", + "data": compressed_application_data, + }, + { + "sequencenumber": 2, + "messageid": self.message_id, + "endofmsg": True, + "data": "", + }, + ] + + for payload_dict in payload_dicts: + payload_chunk = self.encrypt(json.dumps(payload_dict, separators=(",", ":"))) + message += json.dumps( + { + "payload": base64.standard_b64encode(payload_chunk.encode("utf-8")).decode("utf-8"), + "signature": self.sign(payload_chunk).decode("utf-8"), + }, + separators=(",", ":"), + ) + return message + + # ----------------------------------------------------------------------- + # Message parsing / decryption + # ----------------------------------------------------------------------- + + def decrypt_payload_chunks(self, payload_chunks: List[Dict[str, str]]) -> Any: + if not self.keys.encryption: + raise ValueError("Encryption key is not available") + + raw_data = "" + for payload_chunk in payload_chunks: + chunk_json = json.loads( + base64.standard_b64decode(payload_chunk["payload"]).decode("utf-8") + ) + decrypted = AES.new( + key=self.keys.encryption, + mode=AES.MODE_CBC, + iv=base64.standard_b64decode(chunk_json["iv"]), + ).decrypt(base64.standard_b64decode(chunk_json["ciphertext"])) + decrypted = Padding.unpad(decrypted, 16) + payload_json = json.loads(decrypted.decode("utf-8")) + + payload_data = base64.standard_b64decode(payload_json["data"]) + if payload_json.get("compressionalgo") == "GZIP": + payload_data = zlib.decompress(payload_data, 16 + zlib.MAX_WBITS) + raw_data += payload_data.decode("utf-8") + + if not raw_data: + return None + + try: + data = json.loads(raw_data) + except Exception: + return raw_data + + if "error" in data: + return None + if "result" not in data: + return data + return data["result"] + + def parse_message(self, message: str) -> Tuple[Dict[str, Any], Any]: + parsed = self.parse_concatenated_json(message) + header = parsed[0] + encrypted_chunks = parsed[1:] if len(parsed) > 1 else [] + payload = self.decrypt_payload_chunks(encrypted_chunks) if encrypted_chunks else {} + return header, payload + + # ----------------------------------------------------------------------- + # Send message (common path – subclasses may override for extra checks) + # ----------------------------------------------------------------------- + + def send_message( + self, + endpoint: str, + params: Dict[str, str], + application_data: Dict[str, Any], + userauthdata: Optional[dict] = None, + headers: Optional[dict] = None, + proxy: Optional[Dict[str, str]] = None, + timeout: int = 30, + ) -> Tuple[Dict[str, Any], Any]: + message = self.create_message(application_data, userauthdata) + request_kwargs: Dict[str, Any] = { + "url": endpoint, + "data": message, + "params": params, + "headers": headers, + "timeout": timeout, + } + if proxy: + request_kwargs["proxies"] = proxy + + res = self.session.post(**request_kwargs) + + if res.status_code != 200: + raise RuntimeError( + f"MSL request failed with HTTP {res.status_code}: {res.text[:500]}" + ) + + response_text = res.text or "" + stripped = response_text.lstrip() + if not stripped: + raise RuntimeError("MSL request failed: empty response body") + if not stripped.startswith("{"): + raise RuntimeError( + "MSL request failed: the server did not return concatenated MSL JSON. " + f"Content-Type: {res.headers.get('content-type', '')!r}. " + f"Body preview: {response_text[:500]!r}" + ) + + header, payload_data = self.parse_message(response_text) + if not header: + raise RuntimeError( + f"MSL request failed: parsed response does not contain a header. " + f"Body preview: {response_text[:500]!r}" + ) + if "errordata" in header: + decoded_error = json.loads( + base64.standard_b64decode(header["errordata"].encode("utf-8")).decode("utf-8") + ) + raise RuntimeError(f"MSL response contains an error: {decoded_error}") + + return header, payload_data + + # ----------------------------------------------------------------------- + # PBO payload normalisation (shared by Android, iOS, TV) + # ----------------------------------------------------------------------- + + @classmethod + def normalize_application_data(cls, endpoint: str, application_data: Any) -> Any: + if not isinstance(application_data, dict): + return application_data + if cls._looks_like_wrapped_pbo_payload(application_data): + return application_data + + route = cls._extract_pbo_route(application_data, endpoint) + if route is None: + return application_data + + common = dict(getattr(cls, "DEFAULT_PBO_COMMON", {})) + if isinstance(application_data.get("common"), dict): + common.update(application_data["common"]) + + wrapped: Dict[str, Any] = { + "version": application_data.get("version", cls.DEFAULT_PBO_VERSION), + "common": common, + "url": route, + "languages": application_data.get("languages", list(cls.DEFAULT_PBO_LANGUAGES)), + "params": application_data.get("params", {}), + } + + for key in ("path", "method", "route", "endpoint"): + wrapped.pop(key, None) + + for key, value in application_data.items(): + if key in wrapped or key in { + "version", "common", "languages", "params", + "path", "method", "route", "endpoint", + }: + continue + wrapped[key] = value + return wrapped + + @staticmethod + def _looks_like_wrapped_pbo_payload(application_data: Dict[str, Any]) -> bool: + return ( + "version" in application_data + and "common" in application_data + and "url" in application_data + and "languages" in application_data + and "params" in application_data + ) + + @staticmethod + def _extract_pbo_route(application_data: Dict[str, Any], endpoint: str) -> Optional[str]: + for key in ("url", "path", "route", "method", "endpoint"): + value = application_data.get(key) + if not isinstance(value, str) or not value.strip(): + continue + value = value.strip() + return value if value.startswith("/") else f"/{value}" + + endpoint_lower = endpoint.lower() + if "manifest" in endpoint_lower: + return "/manifest" + if "pbo_tokens" in endpoint_lower or "pbo_config" in endpoint_lower: + return None + return None + + # ----------------------------------------------------------------------- + # Cache I/O + # ----------------------------------------------------------------------- + + @staticmethod + def load_cache_data(msl_keys_path: Optional[Path] = None) -> Optional[MSLKeys]: + """Load cached MSL keys from disk. Returns ``None`` if the cache is + missing, corrupt, or the token is about to expire (< 10 h remaining).""" + if not msl_keys_path or not msl_keys_path.is_file(): + return None + + msl_keys = jsonpickle.decode(msl_keys_path.read_text(encoding="utf-8")) + if msl_keys.mastertoken: + tokendata = json.loads( + base64.standard_b64decode(msl_keys.mastertoken["tokendata"]).decode("utf-8") + ) + renewal_window = datetime.fromtimestamp( + int(tokendata["renewalwindow"]), tz=timezone.utc + ) + remaining_hours = (renewal_window - datetime.now(timezone.utc)).total_seconds() / 3600 + if remaining_hours < 10: + return None + return msl_keys + + @staticmethod + def cache_keys(msl_keys: MSLKeys, msl_keys_path: Path) -> None: + """Persist *msl_keys* to *msl_keys_path*.""" + msl_keys_path.parent.mkdir(parents=True, exist_ok=True) + msl_keys_path.write_text(jsonpickle.encode(msl_keys, indent=4), encoding="utf-8") diff --git a/modules/msl_mgk.py b/modules/msl_mgk.py index 69e77d0..d1ca580 100644 --- a/modules/msl_mgk.py +++ b/modules/msl_mgk.py @@ -1,6 +1,6 @@ from __future__ import annotations + import base64 -import gzip import json import os import random @@ -8,9 +8,9 @@ import re import zlib from datetime import datetime, timezone from enum import Enum -from io import BytesIO from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union + import jsonpickle import requests from Cryptodome.Cipher import AES @@ -18,13 +18,16 @@ from Cryptodome.Hash import HMAC, SHA256, SHA384 from Cryptodome.Random import get_random_bytes from Cryptodome.Util.Padding import pad, unpad - -class MSLObject: - def __repr__(self) -> str: - return f"<{self.__class__.__name__} {self.__dict__}>" +from .msl_base import MSLBase, MSLKeys as _BaseMSLKeys, MSLObject -class MSLKeys(MSLObject): +# --------------------------------------------------------------------------- +# Platform-specific key container +# --------------------------------------------------------------------------- + +class MSLKeys(_BaseMSLKeys): + """MGK MSL keys – extends the base with wrapping data and derivation key.""" + def __init__( self, encryption: Optional[bytes] = None, @@ -33,45 +36,63 @@ class MSLKeys(MSLObject): wrapdata: Optional[bytes] = None, derivation_key: Optional[bytes] = None, ) -> None: - self.encryption = encryption - self.sign = sign - self.mastertoken = mastertoken + super().__init__(encryption=encryption, sign=sign, mastertoken=mastertoken) self.wrapdata = wrapdata self.derivation_key = derivation_key +# --------------------------------------------------------------------------- +# Authentication schemes +# --------------------------------------------------------------------------- + class Scheme(Enum): + """Base enum for MSL authentication schemes.""" + def __str__(self) -> str: return str(self.value) class EntityAuthenticationSchemes(Scheme): + """Supported entity authentication schemes for MGK.""" + ModelGroup = "MGK" class UserAuthenticationSchemes(Scheme): + """Supported user authentication schemes for MGK.""" + EmailPassword = "EMAIL_PASSWORD" NetflixIDCookies = "NETFLIXID" UserIDToken = "USER_ID_TOKEN" class EntityAuthentication(MSLObject): - def __init__(self, scheme: EntityAuthenticationSchemes, authdata: Dict[str, Any]) -> None: + """Entity authentication data for MGK handshake.""" + + def __init__( + self, scheme: EntityAuthenticationSchemes, authdata: Dict[str, Any] + ) -> None: self.scheme = str(scheme) self.authdata = authdata @classmethod def ModelGroup(cls, identity: str) -> "EntityAuthentication": + """Create an MGK entity authentication with the given identity.""" return cls(EntityAuthenticationSchemes.ModelGroup, {"identity": identity}) class UserAuthentication(MSLObject): - def __init__(self, scheme: UserAuthenticationSchemes, authdata: Dict[str, Any]) -> None: + """User authentication data for MGK requests.""" + + def __init__( + self, scheme: UserAuthenticationSchemes, authdata: Dict[str, Any] + ) -> None: self.scheme = str(scheme) self.authdata = authdata @classmethod def EmailPassword(cls, email: str, password: str) -> "UserAuthentication": + """Create an email/password user authentication.""" return cls( UserAuthenticationSchemes.EmailPassword, {"email": email, "password": password}, @@ -84,6 +105,7 @@ class UserAuthentication(MSLObject): signature: str, master_token: dict, ) -> "UserAuthentication": + """Create a user-ID-token authentication.""" return cls( UserAuthenticationSchemes.UserIDToken, { @@ -101,6 +123,7 @@ class UserAuthentication(MSLObject): netflixid: Optional[str], securenetflixid: Optional[str], ) -> "UserAuthentication": + """Create a Netflix-ID-cookie-based authentication.""" return cls( UserAuthenticationSchemes.NetflixIDCookies, { @@ -110,169 +133,80 @@ class UserAuthentication(MSLObject): ) -class MSL_MGK: - DEFAULT_MANIFEST_ENDPOINT = "https://api-global.netflix.com/playapi/nrdjs/manifest/1" - DEFAULT_MANIFEST_PARAMS = { +# --------------------------------------------------------------------------- +# MSL_MGK +# --------------------------------------------------------------------------- + +class MSL_MGK(MSLBase): + """Netflix MSL client using AUTHENTICATED_DH (Model Group Key) exchange. + + This platform is fundamentally different from the Widevine/RSA variants: + it uses Diffie-Hellman key agreement with entity authentication, has its + own wrapping key derivation, and structures MSL messages with a single + payload chunk instead of two. + """ + + # -- Platform constants -------------------------------------------------- + DEFAULT_MANIFEST_ENDPOINT: str = ( + "https://api-global.netflix.com/playapi/nrdjs/manifest/1" + ) + DEFAULT_MANIFEST_PARAMS: Dict[str, str] = { "ab_ui_ver": "darwin", "nrdapp_version": "2025.2.2.0", } - DEFAULT_USER_AGENT = ( + DEFAULT_USER_AGENT: str = ( "Netflix/2025.2.2.0 " "(DEVTYPE=NFANDROID2-PRV-NVIDIASHIELDANDROIDTV2019; " "Milo=1.0.6315; build_number=6315; build_sha=a1b915de)" ) - DEFAULT_REQUEST_CONTEXT = '{"appstate":"foreground","reason":"unknown"}' - DEFAULT_NRDJS_VERSION = "v3.11.512" - DEFAULT_NETJS_VERSION = "3.0.5" - DEFAULT_PBO_COMMON = { + DEFAULT_REQUEST_CONTEXT: str = '{"appstate":"foreground","reason":"unknown"}' + DEFAULT_NRDJS_VERSION: str = "v3.11.512" + DEFAULT_NETJS_VERSION: str = "3.0.5" + DEFAULT_PBO_COMMON: Dict[str, str] = { "sdk": "2025.2.2.0", "platform": "2025.2.2.0", "application": ( "12.1.6-23045 R 2025.2 android-30-JPLAYER2 " - "ninja_6==NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/7825230_4040.2147:user/release-keys" + "ninja_6==NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/" + "7825230_4040.2147:user/release-keys" + ), + "uiversion": ( + "UI-release-20260303_43809-gibbon-r100-darwinql-69067=" + "5,78214=2,78929=8,80045=1,80048=2" ), - "uiversion": "UI-release-20260303_43809-gibbon-r100-darwinql-69067=5,78214=2,78929=8,80045=1,80048=2", "uiPlatform": "tv_ui", "clientVersion": "v3.11.512", "apkVersion": "12.1.6", } - DEFAULT_PBO_LANGUAGES = ["en-CA", "en-US", "en"] - WRAP_SALT = bytes.fromhex("027617984f6227539a630b897c017d69") - WRAP_INFO = bytes.fromhex("809f82a7addf548d3ea9dd067ff9bb91") - DH_PRIME = bytes( + DEFAULT_PBO_LANGUAGES: List[str] = ["en-CA", "en-US", "en"] + + # -- DH / wrapping constants --------------------------------------------- + WRAP_SALT: bytes = bytes.fromhex("027617984f6227539a630b897c017d69") + WRAP_INFO: bytes = bytes.fromhex("809f82a7addf548d3ea9dd067ff9bb91") + DH_PRIME: bytes = bytes( [ - 0x96, - 0x94, - 0xE9, - 0xD8, - 0xD9, - 0x3A, - 0x5A, - 0xC7, - 0x4C, - 0x50, - 0x9B, - 0x4B, - 0xBC, - 0xE8, - 0x5E, - 0x92, - 0x13, - 0x2C, - 0xD1, - 0x9C, - 0xCE, - 0x47, - 0x7D, - 0x1A, - 0x7E, - 0x47, - 0xD5, - 0x27, - 0xD9, - 0xEC, - 0x29, - 0x15, - 0x15, - 0xF0, - 0xB8, - 0xB3, - 0xE1, - 0xEA, - 0xED, - 0x50, - 0x06, - 0xE1, - 0xB1, - 0xB9, - 0x1E, - 0xA2, - 0x5B, - 0x91, - 0xA0, - 0x1B, - 0x10, - 0xE2, - 0xE8, - 0x34, - 0xB8, - 0xD6, - 0x60, - 0xB2, - 0xE3, - 0x21, - 0xAD, - 0x64, - 0x4C, - 0xE1, - 0xA8, - 0x3B, - 0x32, - 0x8D, - 0x90, - 0x14, - 0xEE, - 0x7E, - 0x16, - 0xF1, - 0xE4, - 0x4F, - 0xFE, - 0x89, - 0x57, - 0x9A, - 0xC3, - 0xEE, - 0x47, - 0xD6, - 0x68, - 0xB6, - 0xB7, - 0x66, - 0x87, - 0xC2, - 0xFE, - 0x90, - 0xA3, - 0x5B, - 0x5E, - 0x60, - 0x28, - 0xFD, - 0x04, - 0xEF, - 0xEA, - 0x88, - 0x23, - 0x73, - 0xEC, - 0xF6, - 0x0B, - 0xA2, - 0xF6, - 0x37, - 0xE4, - 0xCD, - 0xAA, - 0x1B, - 0x60, - 0x89, - 0xD6, - 0xC0, - 0xB5, - 0x61, - 0xA8, - 0xE5, - 0x20, - 0xE7, - 0x96, - 0xDE, - 0x27, - 0xDF, + 0x96, 0x94, 0xE9, 0xD8, 0xD9, 0x3A, 0x5A, 0xC7, + 0x4C, 0x50, 0x9B, 0x4B, 0xBC, 0xE8, 0x5E, 0x92, + 0x13, 0x2C, 0xD1, 0x9C, 0xCE, 0x47, 0x7D, 0x1A, + 0x7E, 0x47, 0xD5, 0x27, 0xD9, 0xEC, 0x29, 0x15, + 0x15, 0xF0, 0xB8, 0xB3, 0xE1, 0xEA, 0xED, 0x50, + 0x06, 0xE1, 0xB1, 0xB9, 0x1E, 0xA2, 0x5B, 0x91, + 0xA0, 0x1B, 0x10, 0xE2, 0xE8, 0x34, 0xB8, 0xD6, + 0x60, 0xB2, 0xE3, 0x21, 0xAD, 0x64, 0x4C, 0xE1, + 0xA8, 0x3B, 0x32, 0x8D, 0x90, 0x14, 0xEE, 0x7E, + 0x16, 0xF1, 0xE4, 0x4F, 0xFE, 0x89, 0x57, 0x9A, + 0xC3, 0xEE, 0x47, 0xD6, 0x68, 0xB6, 0xB7, 0x66, + 0x87, 0xC2, 0xFE, 0x90, 0xA3, 0x5B, 0x5E, 0x60, + 0x28, 0xFD, 0x04, 0xEF, 0xEA, 0x88, 0x23, 0x73, + 0xEC, 0xF6, 0x0B, 0xA2, 0xF6, 0x37, 0xE4, 0xCD, + 0xAA, 0x1B, 0x60, 0x89, 0xD6, 0xC0, 0xB5, 0x61, + 0xA8, 0xE5, 0x20, 0xE7, 0x96, 0xDE, 0x27, 0xDF, ] ) - DH_P = int.from_bytes(DH_PRIME, "big") - DH_G = 5 + DH_P: int = int.from_bytes(DH_PRIME, "big") + DH_G: int = 5 + + # -- Constructor --------------------------------------------------------- def __init__( self, @@ -283,194 +217,21 @@ class MSL_MGK: user_auth: Optional[dict] = None, cookies: Optional[Dict[str, str]] = None, ) -> None: - self.session = session - self.sender = sender - self.keys = keys - self.message_id = message_id + super().__init__(session=session, keys=keys, message_id=message_id, sender=sender) self.user_auth = user_auth self.cookies = cookies - @staticmethod - def build_request_headers( - request_name: str, - user_agent: Optional[str] = None, - referer: Optional[str] = None, - viewable_id: Optional[int] = None, - profile_guid: Optional[str] = None, - esn: Optional[str] = None, - expiry_timeout: Optional[int] = 12750, - extra_headers: Optional[Dict[str, str]] = None, - ) -> Dict[str, str]: - headers: Dict[str, str] = { - "User-Agent": user_agent or MSL_MGK.DEFAULT_USER_AGENT, - "Accept": "*/*", - "Content-Type": "application/json", - "X-Netflix.Client.Request.Name": request_name, - "X-Netflix.request.attempt": "1", - "X-Netflix.Request.NonJson.Headers": "true", - "X-Netflix.Request.Client.Context": MSL_MGK.DEFAULT_REQUEST_CONTEXT, - "x-netflix.client.nrdjs.version": MSL_MGK.DEFAULT_NRDJS_VERSION, - "x-netflix.client.netjs.version": MSL_MGK.DEFAULT_NETJS_VERSION, - "x-netflix.client.last-interacted-days": "0", - } - - if expiry_timeout is not None: - headers["x-netflix.request.expiry.timeout"] = str(expiry_timeout) - - if referer: - headers["Referer"] = referer - - if viewable_id is not None: - headers["x-netflix.playback.main-content-viewable-id"] = str(viewable_id) - - if profile_guid: - headers["x-netflix.client.current-profile-guid"] = profile_guid - - if esn: - headers["x-netflix.client.ftl.esn"] = esn - - if extra_headers: - headers.update(extra_headers) - - return headers - - @staticmethod - def manifest_request_defaults() -> Tuple[str, Dict[str, str]]: - return MSL_MGK.DEFAULT_MANIFEST_ENDPOINT, dict(MSL_MGK.DEFAULT_MANIFEST_PARAMS) - - @staticmethod - def stable_json(obj: Dict[str, Any]) -> str: - return json.dumps(obj, separators=(",", ":"), ensure_ascii=False) - - @staticmethod - def parse_concatenated_json(message: str) -> List[Dict[str, Any]]: - decoder = json.JSONDecoder() - items: List[Dict[str, Any]] = [] - index = 0 - length = len(message) - - while index < length: - while index < length and message[index].isspace(): - index += 1 - - if index >= length: - break - - item, next_index = decoder.raw_decode(message, index) - items.append(item) - index = next_index - - return items - - @staticmethod - def b64_encode_bytes(value: bytes) -> str: - return base64.b64encode(value).decode("ascii") - - @staticmethod - def b64_decode_strict(value: str) -> bytes: - normalized_value = value.strip().strip('"').strip("'") - return base64.b64decode(normalized_value.encode("ascii"), validate=True) + # ----------------------------------------------------------------------- + # DH key agreement & wrapping + # ----------------------------------------------------------------------- @classmethod - def find_sidecar_file(cls, filename: str, env_name: str) -> Optional[Path]: - env_value = os.getenv(env_name) - candidates: List[Path] = [] - - if env_value: - candidates.append(Path(env_value)) - - roots = [Path.cwd(), Path(__file__).resolve().parent] - - for root in roots: - candidates.append(root / filename) - candidates.extend(root.glob(f"**/{filename}")) - - seen = set() - - for candidate in candidates: - key = str(candidate.resolve()) if candidate.exists() else str(candidate) - - if key in seen: - continue - - seen.add(key) - - if candidate.is_file(): - return candidate - - return None - - @staticmethod - def load_esnid_file(path: Path) -> str: - value = path.read_text(encoding="utf-8", errors="ignore").strip() - - if not value: - raise ValueError(f"Empty ESNID file: {path}") - - return value - - @classmethod - def load_kpe_kph_file(cls, path: Path) -> Tuple[bytes, bytes, bytes]: - raw = path.read_bytes() - - if raw.startswith(b"\xef\xbb\xbf"): - raw = raw[3:] - - text = raw.decode("utf-8", errors="strict").strip() - text = re.sub(r"\s*,\s*", ",", text) - left, right = text.split(",", 1) - enc_key = cls.b64_decode_strict(left) - hmac_key = cls.b64_decode_strict(right) - - if len(enc_key) != 16: - raise ValueError(f"Kpe must decode to 16 bytes, got {len(enc_key)}") - - if len(hmac_key) != 32: - raise ValueError(f"Kph must decode to 32 bytes, got {len(hmac_key)}") - - wrap_key = cls.derive_wrapping_key(enc_key, hmac_key) - return enc_key, hmac_key, wrap_key - - @classmethod - def load_cache_data(cls, msl_keys_path: Optional[Path] = None) -> Optional[MSLKeys]: - if not msl_keys_path or not msl_keys_path.is_file(): - return None - - loaded_keys = jsonpickle.decode(msl_keys_path.read_text(encoding="utf-8")) - - if not isinstance(loaded_keys, MSLKeys): - return None - - if loaded_keys.mastertoken: - expiry_value = json.loads( - base64.b64decode(loaded_keys.mastertoken["tokendata"]).decode("utf-8") - ).get("expiration") - - if expiry_value is not None: - expiry = datetime.fromtimestamp(int(expiry_value), tz=timezone.utc) - hours_remaining = (expiry - datetime.now(timezone.utc)).total_seconds() / 3600 - - if hours_remaining < 10: - return None - - if not hasattr(loaded_keys, "wrapdata"): - loaded_keys.wrapdata = None - - if not hasattr(loaded_keys, "derivation_key"): - loaded_keys.derivation_key = None - - return loaded_keys - - @staticmethod - def cache_keys(msl_keys: MSLKeys, msl_keys_path: Path) -> None: - msl_keys_path.parent.mkdir(parents=True, exist_ok=True) - msl_keys_path.write_text(jsonpickle.encode(msl_keys, indent=4), encoding="utf-8") - - @classmethod - def derive_wrapping_key(cls, encryption_key_16: bytes, hmac_key_32: bytes) -> bytes: + def derive_wrapping_key( + cls, encryption_key_16: bytes, hmac_key_32: bytes + ) -> bytes: + """Derive a 16-byte wrapping key from encryption and HMAC keys.""" if len(encryption_key_16) != 16: raise ValueError("encryptionKey must be 16 bytes") - if len(hmac_key_32) != 32: raise ValueError("hmacKey must be 32 bytes") @@ -484,40 +245,42 @@ class MSL_MGK: @staticmethod def int_to_unsigned_bytes(value: int) -> bytes: + """Encode a non-negative integer as big-endian unsigned bytes.""" if value < 0: raise ValueError("value must be non-negative") - if value == 0: return b"\x00" - return value.to_bytes((value.bit_length() + 7) // 8, "big", signed=False) @staticmethod def correct_null_bytes(value: bytes) -> bytes: + """Ensure at most one leading null byte (DH public-key encoding).""" count = 0 - for byte in value: if byte == 0: count += 1 else: break - if count == 1: return value - trimmed = value[count:] return b"\x00" + trimmed @classmethod def dh_generate_keypair(cls) -> Tuple[int, bytes]: - private_key = int.from_bytes(os.urandom(len(cls.DH_PRIME)), "big") % (cls.DH_P - 3) + 2 + """Generate a Diffie-Hellman key pair. Returns (private_key, public_key_bytes).""" + private_key = int.from_bytes(os.urandom(len(cls.DH_PRIME)), "big") % ( + cls.DH_P - 3 + ) + 2 public_key = pow(cls.DH_G, private_key, cls.DH_P) public_key_bytes = cls.int_to_unsigned_bytes(public_key) - return private_key, cls.correct_null_bytes(public_key_bytes) @classmethod - def dh_compute_shared_secret_bytes(cls, dh_private_key: int, server_public_key_wire: bytes) -> bytes: + def dh_compute_shared_secret_bytes( + cls, dh_private_key: int, server_public_key_wire: bytes + ) -> bytes: + """Compute the DH shared secret from our private key and the server's public key.""" normalized_server_public_key = cls.correct_null_bytes(server_public_key_wire) server_public_key_raw = ( normalized_server_public_key[1:] @@ -526,11 +289,16 @@ class MSL_MGK: ) server_public_key = int.from_bytes(server_public_key_raw, "big", signed=False) shared_secret = pow(server_public_key, dh_private_key, cls.DH_P) - return cls.correct_null_bytes(cls.int_to_unsigned_bytes(shared_secret)) @classmethod - def kdf_authenticated_dh(cls, derivation_key: bytes, shared_secret_bytes: bytes) -> Tuple[bytes, bytes, bytes]: + def kdf_authenticated_dh( + cls, derivation_key: bytes, shared_secret_bytes: bytes + ) -> Tuple[bytes, bytes, bytes]: + """Key derivation for AUTHENTICATED_DH. + + Returns (encryption_key, hmac_key, next_derivation_key). + """ if derivation_key is None: raise ValueError("derivation key is required for AUTHENTICATED_DH") @@ -545,8 +313,15 @@ class MSL_MGK: return encryption_key, hmac_key, wrapping_key + # ----------------------------------------------------------------------- + # MSL v1 encrypt / decrypt / sign / verify (MGK-specific) + # ----------------------------------------------------------------------- + @staticmethod - def msl_encrypt_v1(key_id: str, encryption_key_16: bytes, plaintext_bytes: bytes) -> bytes: + def msl_encrypt_v1( + key_id: str, encryption_key_16: bytes, plaintext_bytes: bytes + ) -> bytes: + """Encrypt data using MSL v1 envelope format (AES-CBC).""" iv = get_random_bytes(16) ciphertext = AES.new(encryption_key_16, AES.MODE_CBC, iv).encrypt( pad(plaintext_bytes, AES.block_size) @@ -557,34 +332,114 @@ class MSL_MGK: "ciphertext": base64.b64encode(ciphertext).decode("ascii"), "sha256": "AA==", } - return json.dumps(envelope, separators=(",", ":")).encode("utf-8") @staticmethod def msl_decrypt_v1(encryption_key_16: bytes, envelope_bytes: bytes) -> bytes: + """Decrypt an MSL v1 envelope (AES-CBC).""" envelope = json.loads(envelope_bytes.decode("utf-8")) iv = base64.b64decode(envelope["iv"]) ciphertext = base64.b64decode(envelope["ciphertext"]) - padded_plaintext = AES.new(encryption_key_16, AES.MODE_CBC, iv).decrypt(ciphertext) - + padded_plaintext = AES.new(encryption_key_16, AES.MODE_CBC, iv).decrypt( + ciphertext + ) return unpad(padded_plaintext, AES.block_size) @staticmethod def msl_sign_b64(hmac_key_32: bytes, data_bytes: bytes) -> str: + """Compute an HMAC-SHA256 signature and return it base64-encoded.""" signer = HMAC.new(hmac_key_32, digestmod=SHA256) signer.update(data_bytes) - return base64.b64encode(signer.digest()).decode("ascii") @staticmethod - def msl_verify_sig(hmac_key_32: bytes, data_bytes: bytes, signature_b64: str) -> None: + def msl_verify_sig( + hmac_key_32: bytes, data_bytes: bytes, signature_b64: str + ) -> None: + """Verify an HMAC-SHA256 signature. Raises :class:`ValueError` on mismatch.""" signer = HMAC.new(hmac_key_32, digestmod=SHA256) signer.update(data_bytes) expected_signature = signer.digest() received_signature = base64.b64decode(signature_b64) - if expected_signature != received_signature: - raise ValueError("Response signature verification failed: HMAC mismatch") + raise ValueError( + "Response signature verification failed: HMAC mismatch" + ) + + # ----------------------------------------------------------------------- + # Sidecar file helpers + # ----------------------------------------------------------------------- + + @classmethod + def find_sidecar_file(cls, filename: str, env_name: str) -> Optional[Path]: + """Locate a sidecar file by checking an env var, CWD, and module dir.""" + env_value = os.getenv(env_name) + candidates: List[Path] = [] + + if env_value: + candidates.append(Path(env_value)) + + roots = [Path.cwd(), Path(__file__).resolve().parent] + for root in roots: + candidates.append(root / filename) + candidates.extend(root.glob(f"**/{filename}")) + + seen: set[str] = set() + for candidate in candidates: + key = str(candidate.resolve()) if candidate.exists() else str(candidate) + if key in seen: + continue + seen.add(key) + if candidate.is_file(): + return candidate + + return None + + @staticmethod + def load_esnid_file(path: Path) -> str: + """Load an ESNID string from a text file.""" + value = path.read_text(encoding="utf-8", errors="ignore").strip() + if not value: + raise ValueError(f"Empty ESNID file: {path}") + return value + + @classmethod + def load_kpe_kph_file(cls, path: Path) -> Tuple[bytes, bytes, bytes]: + """Load Kpe/Kph keys from a comma-separated base64 file. + + Returns (encryption_key, hmac_key, wrapping_key). + """ + raw = path.read_bytes() + if raw.startswith(b"\xef\xbb\xbf"): + raw = raw[3:] + + text = raw.decode("utf-8", errors="strict").strip() + text = re.sub(r"\s*,\s*", ",", text) + left, right = text.split(",", 1) + enc_key = cls.b64_decode_strict(left) + hmac_key = cls.b64_decode_strict(right) + + if len(enc_key) != 16: + raise ValueError(f"Kpe must decode to 16 bytes, got {len(enc_key)}") + if len(hmac_key) != 32: + raise ValueError(f"Kph must decode to 32 bytes, got {len(hmac_key)}") + + wrap_key = cls.derive_wrapping_key(enc_key, hmac_key) + return enc_key, hmac_key, wrap_key + + # ----------------------------------------------------------------------- + # Custom base64 decode (strict validation) + # ----------------------------------------------------------------------- + + @staticmethod + def b64_decode_strict(value: str) -> bytes: + """Base64-decode a string with strict validation.""" + normalized_value = value.strip().strip('"').strip("'") + return base64.b64decode(normalized_value.encode("ascii"), validate=True) + + # ----------------------------------------------------------------------- + # Custom generate_msg_header (json.dumps, custom languages, no recipient) + # ----------------------------------------------------------------------- @staticmethod def generate_msg_header( @@ -595,6 +450,11 @@ class MSL_MGK: keyrequestdata: Optional[dict] = None, compression: Optional[str] = "GZIP", ) -> str: + """Generate an MSL message header for MGK. + + Uses ``json.dumps`` (not jsonpickle) with compact separators, a + single-language list, and no ``recipient`` field. + """ header_data: Dict[str, Any] = { "messageid": message_id, "renewable": True, @@ -608,15 +468,16 @@ class MSL_MGK: "sender": sender, "nonreplayable": False, } - if userauthdata: header_data["userauthdata"] = userauthdata - if keyrequestdata: header_data["keyrequestdata"] = [keyrequestdata] - return json.dumps(header_data, separators=(",", ":")) + # ----------------------------------------------------------------------- + # AUTHENTICATED_DH handshake + # ----------------------------------------------------------------------- + @classmethod def handshake( cls, @@ -628,7 +489,8 @@ class MSL_MGK: headers: Optional[Dict[str, str]] = None, timeout: int = 30, new_msl: bool = False, - ) -> "MSL": + ) -> "MSL_MGK": + """Perform an AUTHENTICATED_DH key exchange and return a configured instance.""" endpoint = "https://www.netflix.com/msl/playapi/cadmium/licensedmanifest/1" message_id = random.randint(0, pow(2, 52)) @@ -654,15 +516,15 @@ class MSL_MGK: resolved_kpekph_path = Path(kpekph_path) else: resolved_kpekph_path = cls.find_sidecar_file("KpeKph", "MSL_KPEKPH_PATH") - if not resolved_kpekph_path: raise RuntimeError( "KpeKph was not found. Place KpeKph next to the client, " - "inside a child folder, in the current working directory, or set MSL_KPEKPH_PATH." + "inside a child folder, in the current working directory, " + "or set MSL_KPEKPH_PATH." ) - entity_encryption_key, entity_hmac_key, entity_wrapping_key = cls.load_kpe_kph_file( - resolved_kpekph_path + entity_encryption_key, entity_hmac_key, entity_wrapping_key = ( + cls.load_kpe_kph_file(resolved_kpekph_path) ) msl_keys = MSLKeys() @@ -674,7 +536,9 @@ class MSL_MGK: dh_private_key, dh_public_key_wire = cls.dh_generate_keypair() key_data: Dict[str, Any] = { "mechanism": mechanism, - "publickey": cls.b64_encode_bytes(cls.correct_null_bytes(dh_public_key_wire)), + "publickey": cls.b64_encode_bytes( + cls.correct_null_bytes(dh_public_key_wire) + ), "parametersid": "1", } @@ -694,7 +558,9 @@ class MSL_MGK: keyrequestdata=key_request_data, compression="GZIP", ).encode("utf-8") - header_ciphertext = cls.msl_encrypt_v1(sender, entity_encryption_key, header_plaintext) + header_ciphertext = cls.msl_encrypt_v1( + sender, entity_encryption_key, header_plaintext + ) payload_plaintext = cls.stable_json( { @@ -704,7 +570,9 @@ class MSL_MGK: "endofmsg": True, } ).encode("utf-8") - payload_ciphertext = cls.msl_encrypt_v1(sender, entity_encryption_key, payload_plaintext) + payload_ciphertext = cls.msl_encrypt_v1( + sender, entity_encryption_key, payload_plaintext + ) request_body = cls.stable_json( { @@ -733,22 +601,26 @@ class MSL_MGK: ) parsed_response = cls.parse_concatenated_json(response.text) - if not parsed_response: raise RuntimeError("Key exchange failed: empty MSL response") key_exchange = parsed_response[0] if "errordata" in key_exchange: - decoded_error = base64.b64decode(key_exchange["errordata"]).decode("utf-8", "ignore") + decoded_error = base64.b64decode(key_exchange["errordata"]).decode( + "utf-8", "ignore" + ) raise RuntimeError(f"Key exchange failed: {decoded_error}") if "headerdata" not in key_exchange: raise RuntimeError( - f"Key exchange failed: missing headerdata in response: {str(key_exchange)[:500]}" + f"Key exchange failed: missing headerdata in response: " + f"{str(key_exchange)[:500]}" ) - header_json = json.loads(base64.b64decode(key_exchange["headerdata"]).decode("utf-8")) + header_json = json.loads( + base64.b64decode(key_exchange["headerdata"]).decode("utf-8") + ) key_response_data = header_json["keyresponsedata"] response_key_data = key_response_data["keydata"] server_public_key_wire = cls.correct_null_bytes( @@ -762,12 +634,10 @@ class MSL_MGK: msl_keys.wrapdata = cached_wrapdata shared_secret = cls.dh_compute_shared_secret_bytes( - dh_private_key, - server_public_key_wire, + dh_private_key, server_public_key_wire ) encryption_key, sign_key, next_derivation_key = cls.kdf_authenticated_dh( - derivation_key, - shared_secret, + derivation_key, shared_secret ) msl_keys.encryption = encryption_key @@ -786,6 +656,59 @@ class MSL_MGK: cookies=cookies, ) + # ----------------------------------------------------------------------- + # Platform-specific request headers + # ----------------------------------------------------------------------- + + @staticmethod + def build_request_headers( + request_name: str, + user_agent: Optional[str] = None, + referer: Optional[str] = None, + viewable_id: Optional[int] = None, + profile_guid: Optional[str] = None, + esn: Optional[str] = None, + expiry_timeout: Optional[int] = 12750, + extra_headers: Optional[Dict[str, str]] = None, + ) -> Dict[str, str]: + """Build the HTTP headers required for MGK MSL requests.""" + headers: Dict[str, str] = { + "User-Agent": user_agent or MSL_MGK.DEFAULT_USER_AGENT, + "Accept": "*/*", + "Content-Type": "application/json", + "X-Netflix.Client.Request.Name": request_name, + "X-Netflix.request.attempt": "1", + "X-Netflix.Request.NonJson.Headers": "true", + "X-Netflix.Request.Client.Context": MSL_MGK.DEFAULT_REQUEST_CONTEXT, + "x-netflix.client.nrdjs.version": MSL_MGK.DEFAULT_NRDJS_VERSION, + "x-netflix.client.netjs.version": MSL_MGK.DEFAULT_NETJS_VERSION, + "x-netflix.client.last-interacted-days": "0", + } + if expiry_timeout is not None: + headers["x-netflix.request.expiry.timeout"] = str(expiry_timeout) + if referer: + headers["Referer"] = referer + if viewable_id is not None: + headers["x-netflix.playback.main-content-viewable-id"] = str(viewable_id) + if profile_guid: + headers["x-netflix.client.current-profile-guid"] = profile_guid + if esn: + headers["x-netflix.client.ftl.esn"] = esn + if extra_headers: + headers.update(extra_headers) + return headers + + # -- Manifest defaults --------------------------------------------------- + + @staticmethod + def manifest_request_defaults() -> Tuple[str, Dict[str, str]]: + """Return the default manifest endpoint and query params for MGK.""" + return MSL_MGK.DEFAULT_MANIFEST_ENDPOINT, dict(MSL_MGK.DEFAULT_MANIFEST_PARAMS) + + # ----------------------------------------------------------------------- + # MGK-specific send_message + # ----------------------------------------------------------------------- + def send_message( self, endpoint: Optional[str] = None, @@ -795,6 +718,10 @@ class MSL_MGK: headers: Optional[Dict[str, str]] = None, timeout: int = 30, ) -> Tuple[Dict[str, Any], Any]: + """Send an MSL message via the MGK flow. + + Raises :class:`RuntimeError` on MSL errors. + """ message = self.create_message(application_data or {}, userauthdata) response = self.session.post( url=endpoint or self.DEFAULT_MANIFEST_ENDPOINT, @@ -806,24 +733,40 @@ class MSL_MGK: ) if response.status_code != 200: - body_preview = response.text[:500] if response.text else response.content[:200].hex() - raise RuntimeError(f"MSL request failed: HTTP {response.status_code} {body_preview}") + body_preview = ( + response.text[:500] + if response.text + else response.content[:200].hex() + ) + raise RuntimeError( + f"MSL request failed: HTTP {response.status_code} {body_preview}" + ) header, payload_data = self.parse_message(response) if "errordata" in header: decoded_error = json.loads( - base64.b64decode(header["errordata"].encode("utf-8")).decode("utf-8") + base64.b64decode(header["errordata"]).decode("utf-8") ) raise RuntimeError(f"MSL response contains an error: {decoded_error}") return header, payload_data + # ----------------------------------------------------------------------- + # MGK-specific create_message (single payload chunk) + # ----------------------------------------------------------------------- + def create_message( self, application_data: Dict[str, Any], userauthdata: Optional[dict] = None, ) -> str: + """Build an MSL request message with a **single** payload chunk. + + Unlike the base class which splits data across two chunks + (data + end-of-msg), MGK puts everything in one chunk with + ``endofmsg=True``. + """ self.message_id += 1 header_data = self.encrypt( @@ -837,7 +780,9 @@ class MSL_MGK: ) message = json.dumps( { - "headerdata": base64.b64encode(header_data.encode("utf-8")).decode("utf-8"), + "headerdata": base64.b64encode(header_data.encode("utf-8")).decode( + "utf-8" + ), "signature": self.sign(header_data).decode("utf-8"), "mastertoken": self.keys.mastertoken, }, @@ -861,7 +806,9 @@ class MSL_MGK: ) message += json.dumps( { - "payload": base64.b64encode(payload_chunk.encode("utf-8")).decode("utf-8"), + "payload": base64.b64encode(payload_chunk.encode("utf-8")).decode( + "utf-8" + ), "signature": self.sign(payload_chunk).decode("utf-8"), }, separators=(",", ":"), @@ -869,7 +816,17 @@ class MSL_MGK: return message + # ----------------------------------------------------------------------- + # MGK-specific parse_message (accepts response objects) + # ----------------------------------------------------------------------- + def parse_message(self, response: Any) -> Tuple[Dict[str, Any], Any]: + """Parse an MSL response. + + Accepts either a :class:`requests.Response` object or a raw string. + Raises :class:`RuntimeError` if the response is empty or not valid + concatenated JSON. + """ if hasattr(response, "text"): message = response.text or "" raw_content = response.content @@ -889,15 +846,14 @@ class MSL_MGK: if not parsed_message: preview = raw_content[:200] - try: preview_text = preview.decode("utf-8", errors="replace") except Exception: preview_text = repr(preview) - raise RuntimeError( "MSL response was empty or not concatenated JSON. " - f"status={status_code} content_type={content_type!r} body_preview={preview_text!r}" + f"status={status_code} content_type={content_type!r} " + f"body_preview={preview_text!r}" ) header = parsed_message[0] @@ -910,7 +866,14 @@ class MSL_MGK: return header, payload_chunks - def decrypt_payload_chunks(self, payload_chunks: List[Dict[str, str]]) -> Any: + # ----------------------------------------------------------------------- + # MGK-specific decrypt_payload_chunks (raises on error) + # ----------------------------------------------------------------------- + + def decrypt_payload_chunks( + self, payload_chunks: List[Dict[str, str]] + ) -> Any: + """Decrypt MSL payload chunks. Raises :class:`RuntimeError` on error.""" if not self.keys.encryption: raise ValueError("Encryption key is not available") @@ -943,28 +906,23 @@ class MSL_MGK: return data["result"] - @staticmethod - def gzip_compress(data: bytes) -> bytes: - output = BytesIO() - - with gzip.GzipFile(fileobj=output, mode="w") as gzip_file: - gzip_file.write(data) - - return base64.b64encode(output.getvalue()) + # ----------------------------------------------------------------------- + # MGK-specific encrypt / sign (use aes_cbc_encrypt from base) + # ----------------------------------------------------------------------- def encrypt(self, plaintext: str) -> str: + """Encrypt plaintext using the negotiated AES-CBC key.""" if not self.keys.encryption: raise ValueError("Encryption key is not available") - if not self.keys.mastertoken: raise ValueError("Master token is not available") iv = os.urandom(16) - token_data = json.loads(base64.b64decode(self.keys.mastertoken["tokendata"]).decode("utf-8")) + token_data = json.loads( + base64.b64decode(self.keys.mastertoken["tokendata"]).decode("utf-8") + ) ciphertext = self.aes_cbc_encrypt( - self.keys.encryption, - iv, - plaintext.encode("utf-8"), + self.keys.encryption, iv, plaintext.encode("utf-8") ) return json.dumps( @@ -978,31 +936,58 @@ class MSL_MGK: ) def sign(self, text: str) -> bytes: + """Sign text using the negotiated HMAC key.""" if not self.keys.sign: raise ValueError("Sign key is not available") signer = HMAC.new(self.keys.sign, digestmod=SHA256) signer.update(text.encode("utf-8")) - return base64.b64encode(signer.digest()) - @staticmethod - def aes_cbc_encrypt(key: bytes, iv: bytes, plaintext: bytes) -> bytes: - return AES.new(key, AES.MODE_CBC, iv).encrypt(pad(plaintext, AES.block_size)) + # ----------------------------------------------------------------------- + # Cache I/O override (checks expiration, not renewalwindow) + # ----------------------------------------------------------------------- - @staticmethod - def aes_cbc_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes: - padded_plaintext = AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext) - return unpad(padded_plaintext, AES.block_size) + @classmethod + def load_cache_data(cls, msl_keys_path: Optional[Path] = None) -> Optional[MSLKeys]: + """Load cached keys, checking the ``expiration`` field in the token.""" + if not msl_keys_path or not msl_keys_path.is_file(): + return None + + loaded_keys = jsonpickle.decode(msl_keys_path.read_text(encoding="utf-8")) + + if not isinstance(loaded_keys, MSLKeys): + return None + + if loaded_keys.mastertoken: + expiry_value = json.loads( + base64.b64decode(loaded_keys.mastertoken["tokendata"]).decode("utf-8") + ).get("expiration") + + if expiry_value is not None: + expiry = datetime.fromtimestamp(int(expiry_value), tz=timezone.utc) + hours_remaining = ( + (expiry - datetime.now(timezone.utc)).total_seconds() / 3600 + ) + if hours_remaining < 10: + return None + + if not hasattr(loaded_keys, "wrapdata"): + loaded_keys.wrapdata = None + if not hasattr(loaded_keys, "derivation_key"): + loaded_keys.derivation_key = None + + return loaded_keys + + # cache_keys inherited from MSLBase (identical implementation) __all__ = [ "EntityAuthentication", "EntityAuthenticationSchemes", - "MSL", + "MSL_MGK", "MSLKeys", - "MSLObject", "Scheme", "UserAuthentication", "UserAuthenticationSchemes", -] \ No newline at end of file +] diff --git a/modules/msl_tv.py b/modules/msl_tv.py index a270b84..68d7bae 100644 --- a/modules/msl_tv.py +++ b/modules/msl_tv.py @@ -1,30 +1,28 @@ +from __future__ import annotations + import base64 -import gzip import json import random -import sys -import zlib -import jsonpickle -import requests -from io import BytesIO from pathlib import Path -from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple -from Cryptodome.Cipher import AES, PKCS1_OAEP -from Cryptodome.Hash import HMAC, SHA256 + +import jsonpickle +import requests +from Cryptodome.Cipher import PKCS1_OAEP from Cryptodome.PublicKey import RSA from Cryptodome.PublicKey.RSA import RsaKey -from Cryptodome.Random import get_random_bytes -from Cryptodome.Util import Padding from pywidevine import Cdm as WidevineCdm, Device as WidevineDevice, PSSH - -class MSLObject: - def __repr__(self) -> str: - return f"<{self.__class__.__name__} {jsonpickle.encode(self, unpicklable=False)}>" +from .msl_base import MSLBase, MSLKeys as _BaseMSLKeys, get_widevine_key -class MSLKeys(MSLObject): +# --------------------------------------------------------------------------- +# Platform-specific key container +# --------------------------------------------------------------------------- + +class MSLKeys(_BaseMSLKeys): + """TV MSL keys – extends the base with an RSA key and CDM session.""" + def __init__( self, encryption: Optional[bytes] = None, @@ -32,41 +30,59 @@ class MSLKeys(MSLObject): rsa: Optional[RsaKey] = None, mastertoken: Optional[dict] = None, cdm_session: Any = None, - ): - self.encryption = encryption - self.sign = sign + ) -> None: + super().__init__(encryption=encryption, sign=sign, mastertoken=mastertoken) self.rsa = rsa - self.mastertoken = mastertoken self.cdm_session = cdm_session -class MSL_TV: - DEFAULT_HANDSHAKE_ENDPOINT = "https://nrdp25.prod.ftl.netflix.com/nq/nrdjs/pbo_tokens/%5E1.0.0/router" - DEFAULT_MANIFEST_ENDPOINT = "https://api-global.netflix.com/playapi/nrdjs/manifest/1" - DEFAULT_MANIFEST_PARAMS = { +# --------------------------------------------------------------------------- +# MSL_TV +# --------------------------------------------------------------------------- + +class MSL_TV(MSLBase): + """Netflix MSL client for TV devices, supporting both Widevine and RSA.""" + + # -- Platform constants -------------------------------------------------- + DEFAULT_HANDSHAKE_ENDPOINT: str = ( + "https://nrdp25.prod.ftl.netflix.com/nq/nrdjs/pbo_tokens/%5E1.0.0/router" + ) + DEFAULT_MANIFEST_ENDPOINT: str = ( + "https://api-global.netflix.com/playapi/nrdjs/manifest/1" + ) + DEFAULT_MANIFEST_PARAMS: Dict[str, str] = { "ab_ui_ver": "darwin", "nrdapp_version": "2025.2.3.0", } - DEFAULT_USER_AGENT = ( + DEFAULT_USER_AGENT: str = ( "Netflix/2025.2.3.0 " "(DEVTYPE=NFANDROID2-PRV-NVIDIASHIELDANDROIDTV2019; " "Milo=1.0.6315; build_number=6315; build_sha=a1b915de)" ) - DEFAULT_REQUEST_CONTEXT = '{"appstate":"foreground","reason":"unknown"}' - DEFAULT_NRDJS_VERSION = "v3.12.55" - DEFAULT_NETJS_VERSION = "3.0.5" - DEFAULT_PBO_VERSION = 2 - DEFAULT_PBO_COMMON = { + DEFAULT_REQUEST_CONTEXT: str = '{"appstate":"foreground","reason":"unknown"}' + DEFAULT_NRDJS_VERSION: str = "v3.12.55" + DEFAULT_NETJS_VERSION: str = "3.0.5" + DEFAULT_PBO_VERSION: int = 2 + DEFAULT_PBO_COMMON: Dict[str, str] = { "sdk": "2025.2.3.0", "platform": "2025.2.3.0", - "application": "12.1.9-23083 R 2025.2 android-30-JPLAYER2 ninja_6==NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/7825230_4040.2147:user/release-keys", - "uiversion": "UI-release-20260407_44745-gibbon-r100-darwinql-69067=5,80198=2,80211=3", + "application": ( + "12.1.9-23083 R 2025.2 android-30-JPLAYER2 " + "ninja_6==NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/" + "7825230_4040.2147:user/release-keys" + ), + "uiversion": ( + "UI-release-20260407_44745-gibbon-r100-darwinql-69067=" + "5,80198=2,80211=3" + ), "uiPlatform": "tv_ui", "clientVersion": "v3.12.55", "apkVersion": "12.1.9", } - DEFAULT_PBO_LANGUAGES = ["en-US", "en-PH", "en"] - DEFAULT_DEVICE_MODEL = "NVIDIA_SHIELD%20Android%20TV" + DEFAULT_PBO_LANGUAGES: List[str] = ["en-US", "en-PH", "en"] + DEFAULT_DEVICE_MODEL: str = "NVIDIA_SHIELD%20Android%20TV" + + # -- Constructor --------------------------------------------------------- def __init__( self, @@ -76,14 +92,13 @@ class MSL_TV: sender: str, user_auth: Optional[dict] = None, drm: str = "widevine", - ): - self.session = session - self.keys = keys - self.sender = sender + ) -> None: + super().__init__(session=session, keys=keys, message_id=message_id, sender=sender) self.user_auth = user_auth - self.message_id = message_id self.drm = drm + # -- Dual-mode handshake (Widevine + RSA) -------------------------------- + @classmethod def handshake( cls, @@ -98,17 +113,21 @@ class MSL_TV: endpoint: Optional[str] = None, headers: Optional[Dict[str, str]] = None, ) -> MSLKeys: + """Perform a key exchange using Widevine (if CDM available) or RSA.""" if cookies: session.cookies.update(cookies) cache_path = Path(msl_keys_path) - msl_keys = MSL_TV.load_cache_data(cache_path) + msl_keys = cls.load_cache_data(cache_path) if msl_keys is not None and not new_msl: return msl_keys message_id = random.randint(0, pow(2, 52)) msl_keys = MSLKeys() + + # ---- Choose DRM scheme --------------------------------------------- if not cdm and drm == "widevine": + # No CDM provided – fall back to RSA key exchange msl_keys.rsa = RSA.generate(2048) assert msl_keys.rsa is not None keyrequestdata = { @@ -122,12 +141,15 @@ class MSL_TV: }, } elif drm == "widevine": + # CDM available – use Widevine if not isinstance(cdm, WidevineCdm): device = WidevineDevice.load(cdm_device) cdm = WidevineCdm.from_device(device) cdm_session = cdm.open() msl_keys.cdm_session = cdm_session - challenge = cdm.get_license_challenge(cdm_session, PSSH.new(system_id=PSSH.SystemId.Widevine)) + challenge = cdm.get_license_challenge( + cdm_session, PSSH.new(system_id=PSSH.SystemId.Widevine) + ) wv_request = base64.b64encode(challenge).decode("utf-8") keyrequestdata = { "scheme": "WIDEVINE", @@ -138,6 +160,7 @@ class MSL_TV: else: raise ValueError(f"Unsupported DRM mode: {drm}") + # ---- Build & send handshake request -------------------------------- data = jsonpickle.encode( { "entityauthdata": { @@ -147,7 +170,7 @@ class MSL_TV: }, }, "headerdata": base64.standard_b64encode( - MSL_TV.generate_msg_header( + cls.generate_msg_header( message_id=message_id, sender=sender, is_handshake=True, @@ -181,10 +204,14 @@ class MSL_TV: host="nrdp25.prod.ftl.netflix.com", language="en-US,en-PH,en", ) - res = session.post(url=handshake_endpoint, data=data, headers=handshake_headers, timeout=30) + res = session.post( + url=handshake_endpoint, data=data, headers=handshake_headers, timeout=30 + ) if res.status_code != 200: - raise RuntimeError(f"Key exchange failed: HTTP {res.status_code} {res.text[:500]}") + raise RuntimeError( + f"Key exchange failed: HTTP {res.status_code} {res.text[:500]}" + ) parsed = cls.parse_concatenated_json(res.text) if not parsed: @@ -193,12 +220,17 @@ class MSL_TV: key_exchange = parsed[0] if "errordata" in key_exchange: - decoded_error = base64.standard_b64decode(key_exchange["errordata"]).decode("utf-8") + decoded_error = base64.standard_b64decode( + key_exchange["errordata"] + ).decode("utf-8") error_json = json.loads(decoded_error) raise RuntimeError(f"Key exchange failed: {error_json}") if "headerdata" not in key_exchange: - raise RuntimeError(f"Key exchange failed: missing headerdata in response: {str(key_exchange)[:500]}") + raise RuntimeError( + f"Key exchange failed: missing headerdata in response: " + f"{str(key_exchange)[:500]}" + ) header_json = json.loads( base64.standard_b64decode(key_exchange["headerdata"]).decode("utf-8") @@ -206,37 +238,46 @@ class MSL_TV: key_response_data = header_json["keyresponsedata"] key_data = key_response_data["keydata"] + # ---- Derive encryption / signing keys from response ---------------- if cdm: + # Widevine path cdm.parse_license(msl_keys.cdm_session, key_data["cdmkeyresponse"]) - keys = cdm.get_keys(msl_keys.cdm_session) - msl_keys.encryption = MSL_TV.get_widevine_key( + wv_keys = cdm.get_keys(msl_keys.cdm_session) + msl_keys.encryption = get_widevine_key( kid=base64.standard_b64decode(key_data["encryptionkeyid"]), - keys=keys, + keys=wv_keys, permissions=["AllowEncrypt", "AllowDecrypt"], ) - msl_keys.sign = MSL_TV.get_widevine_key( + msl_keys.sign = get_widevine_key( kid=base64.standard_b64decode(key_data["hmackeyid"]), - keys=keys, + keys=wv_keys, permissions=["AllowSign", "AllowSignatureVerify"], ) else: + # RSA path assert msl_keys.rsa is not None cipher_rsa = PKCS1_OAEP.new(msl_keys.rsa) - msl_keys.encryption = MSL_TV.base64key_decode( + msl_keys.encryption = cls.base64key_decode( json.loads( - cipher_rsa.decrypt(base64.standard_b64decode(key_data["encryptionkey"])).decode("utf-8") + cipher_rsa.decrypt( + base64.standard_b64decode(key_data["encryptionkey"]) + ).decode("utf-8") )["k"] ) - msl_keys.sign = MSL_TV.base64key_decode( + msl_keys.sign = cls.base64key_decode( json.loads( - cipher_rsa.decrypt(base64.standard_b64decode(key_data["hmackey"])).decode("utf-8") + cipher_rsa.decrypt( + base64.standard_b64decode(key_data["hmackey"]) + ).decode("utf-8") )["k"] ) msl_keys.mastertoken = key_response_data["mastertoken"] - MSL_TV.cache_keys(msl_keys, cache_path) + cls.cache_keys(msl_keys, cache_path) return msl_keys + # -- Platform-specific request headers ----------------------------------- + @staticmethod def build_request_headers( request_name: str, @@ -251,6 +292,7 @@ class MSL_TV: language: Optional[str] = "en-US,en-PH,en", device_model: Optional[str] = None, ) -> Dict[str, str]: + """Build the HTTP headers required for TV MSL requests.""" headers: Dict[str, str] = { "Host": host or "nrdp25.prod.ftl.netflix.com", "Language": language or "en-US,en-PH,en", @@ -285,10 +327,15 @@ class MSL_TV: headers.update(extra_headers) return headers + # -- Manifest defaults --------------------------------------------------- + @staticmethod def manifest_request_defaults() -> Tuple[str, Dict[str, str]]: + """Return the default manifest endpoint and query params for TV.""" return MSL_TV.DEFAULT_MANIFEST_ENDPOINT, dict(MSL_TV.DEFAULT_MANIFEST_PARAMS) + # -- Custom generate_msg_header (TV-specific languages) ------------------ + @staticmethod def generate_msg_header( message_id: int, @@ -298,37 +345,19 @@ class MSL_TV: keyrequestdata: Optional[dict] = None, compression: Optional[str] = "GZIP", ) -> str: - header_data: Dict[str, Any] = { - "messageid": message_id, - "renewable": True, - "handshake": is_handshake, - "capabilities": { - "compressionalgos": [compression] if compression else [], - "languages": ["en-US", "en-PH", "en"], - "encoderformats": ["JSON"], - }, - "timestamp": int(datetime.now(timezone.utc).timestamp()), - "sender": sender, - "nonreplayable": False, - "recipient": "Netflix", - } - if userauthdata: - header_data["userauthdata"] = userauthdata - if keyrequestdata: - header_data["keyrequestdata"] = [keyrequestdata] - return jsonpickle.encode(header_data, unpicklable=False) + """Generate an MSL message header with TV-specific language list.""" + return MSLBase.generate_msg_header( + message_id=message_id, + sender=sender, + is_handshake=is_handshake, + userauthdata=userauthdata, + keyrequestdata=keyrequestdata, + compression=compression, + languages=["en-US", "en-PH", "en"], + ) - @staticmethod - def get_widevine_key(kid: bytes, keys: List[Any], permissions: List[str]) -> Optional[bytes]: - import re - normalized_perms = {re.sub(r'(? Tuple[Dict[str, Any], Any]: - normalized_application_data = self.normalize_application_data(endpoint, application_data) - message = self.create_message(normalized_application_data, userauthdata) - request_kwargs: Dict[str, Any] = { - "url": endpoint, - "data": message, - "params": params, - "headers": headers, - "timeout": 30, - } - if proxy: - request_kwargs["proxies"] = proxy - res = self.session.post(**request_kwargs) - header, payload_data = self.parse_message(res.text) - if "errordata" in header: - decoded_error = json.loads( - base64.standard_b64decode(header["errordata"].encode("utf-8")).decode("utf-8") - ) - sys.exit(print(f"MSL response contains an error: {decoded_error}")) - return header, payload_data + """Send an MSL message with PBO payload normalisation. - @classmethod - def normalize_application_data(cls, endpoint: str, application_data: Any) -> Any: - if not isinstance(application_data, dict): - return application_data - - if cls._looks_like_wrapped_pbo_payload(application_data): - return application_data - - route = cls._extract_pbo_route(application_data, endpoint) - if route is None: - return application_data - - common = dict(cls.DEFAULT_PBO_COMMON) - if isinstance(application_data.get("common"), dict): - common.update(application_data["common"]) - - wrapped: Dict[str, Any] = { - "version": application_data.get("version", cls.DEFAULT_PBO_VERSION), - "common": common, - "url": route, - "languages": application_data.get("languages", list(cls.DEFAULT_PBO_LANGUAGES)), - "params": application_data.get("params", {}), - } - - for key in ("path", "method", "route", "endpoint"): - wrapped.pop(key, None) - - for key, value in application_data.items(): - if key in wrapped or key in {"version", "common", "languages", "params", "path", "method", "route", "endpoint"}: - continue - wrapped[key] = value - return wrapped - - @staticmethod - def _looks_like_wrapped_pbo_payload(application_data: Dict[str, Any]) -> bool: - return ( - "version" in application_data - and "common" in application_data - and "url" in application_data - and "languages" in application_data - and "params" in application_data + Raises :class:`RuntimeError` on MSL errors instead of calling + ``sys.exit()``. + """ + normalized = self.normalize_application_data(endpoint, application_data) + return super().send_message( + endpoint=endpoint, + params=params, + application_data=normalized, + userauthdata=userauthdata, + headers=headers, + proxy=proxy, + timeout=timeout, ) - @staticmethod - def _extract_pbo_route(application_data: Dict[str, Any], endpoint: str) -> Optional[str]: - for key in ("url", "path", "route", "method", "endpoint"): - value = application_data.get(key) - if not isinstance(value, str) or not value.strip(): - continue - value = value.strip() - return value if value.startswith("/") else f"/{value}" - - endpoint_lower = endpoint.lower() - if "manifest" in endpoint_lower: - return "/manifest" - if "pbo_tokens" in endpoint_lower or "pbo_config" in endpoint_lower: - return None - return None - - def create_message(self, application_data: Dict[str, Any], userauthdata: Optional[dict] = None) -> str: - self.message_id += 1 - - headerdata = self.encrypt( - self.generate_msg_header( - message_id=self.message_id, - sender=self.sender, - is_handshake=False, - userauthdata=userauthdata, - ) - ) - - message = json.dumps( - { - "headerdata": base64.standard_b64encode(headerdata.encode("utf-8")).decode("utf-8"), - "signature": self.sign(headerdata).decode("utf-8"), - "mastertoken": self.keys.mastertoken, - }, - separators=(",", ":"), - ) - - compressed_application_data = self.gzip_compress( - json.dumps(application_data, separators=(",", ":")).encode("utf-8") - ).decode("utf-8") - payload_dicts = [ - { - "sequencenumber": 1, - "messageid": self.message_id, - "compressionalgo": "GZIP", - "data": compressed_application_data, - }, - { - "sequencenumber": 2, - "messageid": self.message_id, - "endofmsg": True, - "data": "", - }, - ] - - for payload_dict in payload_dicts: - payload_chunk = self.encrypt(json.dumps(payload_dict, separators=(",", ":"))) - message += json.dumps( - { - "payload": base64.standard_b64encode(payload_chunk.encode("utf-8")).decode("utf-8"), - "signature": self.sign(payload_chunk).decode("utf-8"), - }, - separators=(",", ":"), - ) - return message - - def decrypt_payload_chunks(self, payload_chunks: List[Dict[str, str]]) -> Any: - raw_data = "" - assert self.keys.encryption is not None - - for payload_chunk in payload_chunks: - payload_chunk_json = json.loads(base64.standard_b64decode(payload_chunk["payload"]).decode("utf-8")) - payload_decrypted = AES.new( - key=self.keys.encryption, - mode=AES.MODE_CBC, - iv=base64.standard_b64decode(payload_chunk_json["iv"]), - ).decrypt(base64.standard_b64decode(payload_chunk_json["ciphertext"])) - payload_decrypted = Padding.unpad(payload_decrypted, 16) - payload_decrypted_json = json.loads(payload_decrypted.decode("utf-8")) - - payload_data = base64.standard_b64decode(payload_decrypted_json["data"]) - if payload_decrypted_json.get("compressionalgo") == "GZIP": - payload_data = zlib.decompress(payload_data, 16 + zlib.MAX_WBITS) - raw_data += payload_data.decode("utf-8") - - if not raw_data: - return None - - try: - data = json.loads(raw_data) - except Exception: - return raw_data - - if "error" in data: - return None - if "result" not in data: - return data - return data["result"] - - @staticmethod - def parse_concatenated_json(message: str) -> List[Dict[str, Any]]: - decoder = json.JSONDecoder() - items: List[Dict[str, Any]] = [] - index = 0 - length = len(message) - - while index < length: - while index < length and message[index].isspace(): - index += 1 - if index >= length: - break - item, next_index = decoder.raw_decode(message, index) - items.append(item) - index = next_index - return items - - def parse_message(self, message: str) -> Tuple[Dict[str, Any], Any]: - parsed_message = self.parse_concatenated_json(message) - header = parsed_message[0] - encrypted_payload_chunks = parsed_message[1:] if len(parsed_message) > 1 else [] - payload_chunks = self.decrypt_payload_chunks(encrypted_payload_chunks) if encrypted_payload_chunks else {} - return header, payload_chunks - - @staticmethod - def gzip_compress(data: bytes) -> bytes: - out = BytesIO() - with gzip.GzipFile(fileobj=out, mode="w") as gzip_file: - gzip_file.write(data) - return base64.standard_b64encode(out.getvalue()) - - @staticmethod - def base64key_decode(payload: str) -> bytes: - length = len(payload) % 4 - if length == 2: - payload += "==" - elif length == 3: - payload += "=" - elif length != 0: - raise ValueError("Invalid base64 string") - return base64.urlsafe_b64decode(payload.encode("utf-8")) - - def encrypt(self, plaintext: str) -> str: - if not self.keys.encryption: - raise ValueError("Encryption key is not available") - if not self.keys.mastertoken: - raise ValueError("Master token is not available") - - iv = get_random_bytes(16) - tokendata = json.loads(base64.standard_b64decode(self.keys.mastertoken["tokendata"]).decode("utf-8")) - return json.dumps( - { - "ciphertext": base64.standard_b64encode( - AES.new(self.keys.encryption, AES.MODE_CBC, iv).encrypt( - Padding.pad(plaintext.encode("utf-8"), 16) - ) - ).decode("utf-8"), - "keyid": f"{self.sender}_{tokendata['sequencenumber']}", - "sha256": "AA==", - "iv": base64.standard_b64encode(iv).decode("utf-8"), - } - ) - - def sign(self, text: str) -> bytes: - if not self.keys.sign: - raise ValueError("Sign key is not available") - return base64.standard_b64encode(HMAC.new(self.keys.sign, text.encode("utf-8"), SHA256).digest()) + # -- Cache I/O overrides (RSA key serialisation) ------------------------- @staticmethod def load_cache_data(msl_keys_path: Optional[Path] = None) -> Optional[MSLKeys]: - if not msl_keys_path or not msl_keys_path.is_file(): + """Load cached keys, re-importing the RSA key from its PEM form.""" + msl_keys = MSLBase.load_cache_data(msl_keys_path) + if msl_keys is None: return None - - msl_keys = jsonpickle.decode(msl_keys_path.read_text(encoding="utf-8")) - if msl_keys.rsa: + # Re-import RSA key from serialised PEM form + if getattr(msl_keys, "rsa", None): msl_keys.rsa = RSA.import_key(msl_keys.rsa) - - if msl_keys.mastertoken: - tokendata = json.loads(base64.standard_b64decode(msl_keys.mastertoken["tokendata"]).decode("utf-8")) - renewal_window = datetime.fromtimestamp(int(tokendata["renewalwindow"]), tz=timezone.utc) - remaining_hours = (renewal_window - datetime.now(timezone.utc)).total_seconds() / 3600 - if remaining_hours < 10: - return None return msl_keys @staticmethod def cache_keys(msl_keys: MSLKeys, msl_keys_path: Path) -> None: + """Persist keys, exporting the RSA key to PEM for JSON compatibility.""" original_rsa = msl_keys.rsa - exported_rsa = None if msl_keys.rsa: - exported_rsa = msl_keys.rsa.export_key() - msl_keys.rsa = exported_rsa - with open(msl_keys_path, "w", encoding="utf-8") as cache_file: - cache_file.write(jsonpickle.encode(msl_keys, indent=4)) + msl_keys.rsa = msl_keys.rsa.export_key() + MSLBase.cache_keys(msl_keys, msl_keys_path) if original_rsa: - msl_keys.rsa = original_rsa \ No newline at end of file + msl_keys.rsa = original_rsa + + +__all__ = ["MSL_TV", "MSLKeys"] diff --git a/modules/msl_web.py b/modules/msl_web.py index b61471e..717cbbd 100644 --- a/modules/msl_web.py +++ b/modules/msl_web.py @@ -1,54 +1,62 @@ from __future__ import annotations + import base64 -import gzip import json import random -import zlib -import jsonpickle -import requests +from collections import OrderedDict from datetime import datetime, timezone from http.cookiejar import CookieJar -from io import BytesIO from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Tuple -from collections import OrderedDict -from Cryptodome.Cipher import AES, PKCS1_OAEP -from Cryptodome.Hash import HMAC, SHA256 + +import jsonpickle +import requests +from Cryptodome.Cipher import PKCS1_OAEP from Cryptodome.PublicKey import RSA from Cryptodome.PublicKey.RSA import RsaKey -from Cryptodome.Random import get_random_bytes -from Cryptodome.Util import Padding + +from .msl_base import MSLBase, MSLKeys as _BaseMSLKeys -class MSLObject: - def __repr__(self) -> str: - return f"<{self.__class__.__name__} {jsonpickle.encode(self, unpicklable=False)}>" +# --------------------------------------------------------------------------- +# Platform-specific key container +# --------------------------------------------------------------------------- +class MSLKeys(_BaseMSLKeys): + """Web MSL keys – extends the base with an RSA key (no CDM session).""" -class MSLKeys(MSLObject): def __init__( self, encryption: Optional[bytes] = None, sign: Optional[bytes] = None, rsa: Optional[RsaKey] = None, mastertoken: Optional[dict] = None, - ): - self.encryption = encryption - self.sign = sign + ) -> None: + super().__init__(encryption=encryption, sign=sign, mastertoken=mastertoken) self.rsa = rsa - self.mastertoken = mastertoken -class MSL_WEB: - DEFAULT_HANDSHAKE_ENDPOINT = "https://www.netflix.com/nq/msl_v1/nrdjs/pbo_tokens/%5E1.0.0/router" - DEFAULT_USER_AGENT = ( +# --------------------------------------------------------------------------- +# MSL_WEB +# --------------------------------------------------------------------------- + +class MSL_WEB(MSLBase): + """Netflix MSL client for web browsers using RSA key exchange.""" + + # -- Platform constants -------------------------------------------------- + DEFAULT_HANDSHAKE_ENDPOINT: str = ( + "https://www.netflix.com/nq/msl_v1/nrdjs/pbo_tokens/%5E1.0.0/router" + ) + DEFAULT_USER_AGENT: str = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/146.0.0.0 Safari/537.36" ) - DEFAULT_REQUEST_CONTEXT = '{"appstate":"foreground"}' - DEFAULT_NRDJS_VERSION = "v3.11.512" - DEFAULT_NETJS_VERSION = "3.0.5" + DEFAULT_REQUEST_CONTEXT: str = '{"appstate":"foreground"}' + DEFAULT_NRDJS_VERSION: str = "v3.11.512" + DEFAULT_NETJS_VERSION: str = "3.0.5" + + # -- Constructor --------------------------------------------------------- def __init__( self, @@ -57,12 +65,11 @@ class MSL_WEB: message_id: int, sender: str, user_auth: Optional[dict] = None, - ): - self.session = session - self.keys = keys - self.sender = sender + ) -> None: + super().__init__(session=session, keys=keys, message_id=message_id, sender=sender) self.user_auth = user_auth - self.message_id = message_id + + # -- RSA handshake ------------------------------------------------------- @classmethod def handshake( @@ -75,6 +82,7 @@ class MSL_WEB: endpoint: Optional[str] = None, headers: Optional[Dict[str, str]] = None, ) -> MSLKeys: + """Perform an RSA (ASYMMETRIC_WRAPPED) key exchange.""" if cookies: session.cookies.update(cookies) @@ -140,7 +148,9 @@ class MSL_WEB: timeout=30, ) if response.status_code != 200: - raise RuntimeError(f"Key exchange failed: HTTP {response.status_code} {response.text[:500]}") + raise RuntimeError( + f"Key exchange failed: HTTP {response.status_code} {response.text[:500]}" + ) parsed = cls.parse_concatenated_json(response.text) if not parsed: @@ -148,26 +158,42 @@ class MSL_WEB: header = parsed[0] if "errordata" in header: - decoded_error = base64.standard_b64decode(header["errordata"]).decode("utf-8") + decoded_error = base64.standard_b64decode(header["errordata"]).decode( + "utf-8" + ) raise RuntimeError(f"Key exchange failed: {decoded_error}") if "headerdata" not in header: - raise RuntimeError(f"Key exchange failed: missing headerdata: {str(header)[:500]}") + raise RuntimeError( + f"Key exchange failed: missing headerdata: {str(header)[:500]}" + ) - header_json = json.loads(base64.standard_b64decode(header["headerdata"]).decode("utf-8")) + header_json = json.loads( + base64.standard_b64decode(header["headerdata"]).decode("utf-8") + ) key_data = header_json["keyresponsedata"]["keydata"] cipher_rsa = PKCS1_OAEP.new(keys.rsa) keys.encryption = cls.base64key_decode( - json.loads(cipher_rsa.decrypt(base64.standard_b64decode(key_data["encryptionkey"])).decode("utf-8"))["k"] + json.loads( + cipher_rsa.decrypt( + base64.standard_b64decode(key_data["encryptionkey"]) + ).decode("utf-8") + )["k"] ) keys.sign = cls.base64key_decode( - json.loads(cipher_rsa.decrypt(base64.standard_b64decode(key_data["hmackey"])).decode("utf-8"))["k"] + json.loads( + cipher_rsa.decrypt( + base64.standard_b64decode(key_data["hmackey"]) + ).decode("utf-8") + )["k"] ) keys.mastertoken = header_json["keyresponsedata"]["mastertoken"] cls.cache_keys(keys, cache_path) return keys + # -- Platform-specific request headers ----------------------------------- + @staticmethod def build_request_headers( request_name: str, @@ -179,6 +205,7 @@ class MSL_WEB: expiry_timeout: Optional[int] = 12750, extra_headers: Optional[Dict[str, str]] = None, ) -> Dict[str, str]: + """Build the HTTP headers required for Web MSL requests.""" headers: Dict[str, str] = { "User-Agent": user_agent or MSL_WEB.DEFAULT_USER_AGENT, "Accept": "*/*", @@ -207,6 +234,8 @@ class MSL_WEB: headers.update(extra_headers) return headers + # -- Custom generate_msg_header (empty languages / encoderformats) ------- + @staticmethod def generate_msg_header( message_id: int, @@ -216,6 +245,11 @@ class MSL_WEB: keyrequestdata: Optional[dict] = None, compression: Optional[str] = "GZIP", ) -> str: + """Generate an MSL message header with Web-specific capabilities. + + The Web platform sends empty ``languages`` and ``encoderformats`` + arrays in the capabilities block. + """ header_data: Dict[str, Any] = { "messageid": message_id, "renewable": True, @@ -236,6 +270,8 @@ class MSL_WEB: header_data["keyrequestdata"] = [keyrequestdata] return jsonpickle.encode(header_data, unpicklable=False) + # -- Web-specific send_message (no PBO normalisation) -------------------- + def send_message( self, endpoint: str, @@ -244,177 +280,51 @@ class MSL_WEB: userauthdata: Optional[dict] = None, headers: Optional[Dict[str, str]] = None, ) -> Tuple[Dict[str, Any], Any]: + """Send an MSL message without PBO payload normalisation. + + Raises :class:`RuntimeError` on MSL errors. + """ message = self.create_message(application_data, userauthdata) - response = self.session.post(url=endpoint, params=params, data=message, headers=headers, timeout=30) + response = self.session.post( + url=endpoint, params=params, data=message, headers=headers, timeout=30 + ) response.raise_for_status() header, payload = self.parse_message(response.text) if "errordata" in header: - decoded_error = json.loads(base64.standard_b64decode(header["errordata"]).decode("utf-8")) + decoded_error = json.loads( + base64.standard_b64decode(header["errordata"]).decode("utf-8") + ) raise RuntimeError(f"MSL response contains an error: {decoded_error}") return header, payload - def create_message(self, application_data: Any, userauthdata: Optional[dict] = None) -> str: - self.message_id += 1 - headerdata = self.encrypt( - self.generate_msg_header( - message_id=self.message_id, - sender=self.sender, - is_handshake=False, - userauthdata=userauthdata, - ) - ) - - message = json.dumps( - { - "headerdata": base64.standard_b64encode(headerdata.encode("utf-8")).decode("utf-8"), - "signature": self.sign(headerdata).decode("utf-8"), - "mastertoken": self.keys.mastertoken, - }, - separators=(",", ":"), - ) - - compressed_data = self.gzip_compress(json.dumps(application_data, separators=(",", ":")).encode("utf-8")).decode("utf-8") - payloads = [ - { - "sequencenumber": 1, - "messageid": self.message_id, - "compressionalgo": "GZIP", - "data": compressed_data, - }, - { - "sequencenumber": 2, - "messageid": self.message_id, - "endofmsg": True, - "data": "", - }, - ] - - for payload in payloads: - encrypted_chunk = self.encrypt(json.dumps(payload, separators=(",", ":"))) - message += json.dumps( - { - "payload": base64.standard_b64encode(encrypted_chunk.encode("utf-8")).decode("utf-8"), - "signature": self.sign(encrypted_chunk).decode("utf-8"), - }, - separators=(",", ":"), - ) - return message - - def parse_message(self, message: str) -> Tuple[Dict[str, Any], Any]: - parsed = self.parse_concatenated_json(message) - header = parsed[0] - payload_chunks = parsed[1:] if len(parsed) > 1 else [] - payload = self.decrypt_payload_chunks(payload_chunks) if payload_chunks else {} - return header, payload - - def decrypt_payload_chunks(self, payload_chunks: List[Dict[str, str]]) -> Any: - if not self.keys.encryption: - raise ValueError("Encryption key is not available") - - raw_data = "" - for payload_chunk in payload_chunks: - chunk_json = json.loads(base64.standard_b64decode(payload_chunk["payload"]).decode("utf-8")) - decrypted = AES.new( - key=self.keys.encryption, - mode=AES.MODE_CBC, - iv=base64.standard_b64decode(chunk_json["iv"]), - ).decrypt(base64.standard_b64decode(chunk_json["ciphertext"])) - decrypted = Padding.unpad(decrypted, 16) - payload_json = json.loads(decrypted.decode("utf-8")) - - payload_data = base64.standard_b64decode(payload_json["data"]) - if payload_json.get("compressionalgo") == "GZIP": - payload_data = zlib.decompress(payload_data, 16 + zlib.MAX_WBITS) - raw_data += payload_data.decode("utf-8") - - data = json.loads(raw_data) - if "error" in data: - return None - return data.get("result", data) - - def encrypt(self, plaintext: str) -> str: - if not self.keys.encryption: - raise ValueError("Encryption key is not available") - if not self.keys.mastertoken: - raise ValueError("Master token is not available") - - iv = get_random_bytes(16) - tokendata = json.loads(base64.standard_b64decode(self.keys.mastertoken["tokendata"]).decode("utf-8")) - ciphertext = AES.new(self.keys.encryption, AES.MODE_CBC, iv).encrypt(Padding.pad(plaintext.encode("utf-8"), 16)) - return json.dumps( - { - "ciphertext": base64.standard_b64encode(ciphertext).decode("utf-8"), - "keyid": f"{self.sender}_{tokendata['sequencenumber']}", - "sha256": "AA==", - "iv": base64.standard_b64encode(iv).decode("utf-8"), - }, - separators=(",", ":"), - ) - - def sign(self, text: str) -> bytes: - if not self.keys.sign: - raise ValueError("Sign key is not available") - return base64.standard_b64encode(HMAC.new(self.keys.sign, text.encode("utf-8"), SHA256).digest()) - - @staticmethod - def parse_concatenated_json(message: str) -> List[Dict[str, Any]]: - decoder = json.JSONDecoder() - items: List[Dict[str, Any]] = [] - index = 0 - while index < len(message): - while index < len(message) and message[index].isspace(): - index += 1 - if index >= len(message): - break - item, index = decoder.raw_decode(message, index) - items.append(item) - return items - - @staticmethod - def gzip_compress(data: bytes) -> bytes: - out = BytesIO() - with gzip.GzipFile(fileobj=out, mode="w") as handle: - handle.write(data) - return base64.standard_b64encode(out.getvalue()) - - @staticmethod - def base64key_decode(payload: str) -> bytes: - remainder = len(payload) % 4 - if remainder == 2: - payload += "==" - elif remainder == 3: - payload += "=" - elif remainder != 0: - raise ValueError("Invalid base64 string") - return base64.urlsafe_b64decode(payload.encode("utf-8")) + # -- Cache I/O overrides (RSA key serialisation) ------------------------- @staticmethod def load_cache_data(msl_keys_path: Optional[Path] = None) -> Optional[MSLKeys]: - if not msl_keys_path or not msl_keys_path.is_file(): + """Load cached keys, re-importing the RSA key from its PEM form.""" + msl_keys = MSLBase.load_cache_data(msl_keys_path) + if msl_keys is None: return None - - msl_keys = jsonpickle.decode(msl_keys_path.read_text(encoding="utf-8")) - if msl_keys.rsa: + # Re-import RSA key from serialised PEM form + if getattr(msl_keys, "rsa", None): msl_keys.rsa = RSA.import_key(msl_keys.rsa) - - if msl_keys.mastertoken: - tokendata = json.loads(base64.standard_b64decode(msl_keys.mastertoken["tokendata"]).decode("utf-8")) - renewal_window = datetime.fromtimestamp(int(tokendata["renewalwindow"]), tz=timezone.utc) - if (renewal_window - datetime.now(timezone.utc)).total_seconds() / 3600 < 10: - return None return msl_keys @staticmethod def cache_keys(msl_keys: MSLKeys, msl_keys_path: Path) -> None: + """Persist keys, exporting the RSA key to PEM for JSON compatibility.""" original_rsa = msl_keys.rsa if msl_keys.rsa: msl_keys.rsa = msl_keys.rsa.export_key() - msl_keys_path.write_text(jsonpickle.encode(msl_keys, indent=4), encoding="utf-8") + MSLBase.cache_keys(msl_keys, msl_keys_path) if original_rsa: msl_keys.rsa = original_rsa + # -- Cookie-jar helpers (Web-specific) ----------------------------------- + @staticmethod def cookiejar_to_list(cookie_jar: CookieJar) -> List[Dict[str, Any]]: + """Serialise a :class:`CookieJar` to a list of dicts.""" cookies: List[Dict[str, Any]] = [] for cookie in cookie_jar: cookies.append( @@ -433,13 +343,17 @@ class MSL_WEB: @staticmethod def cookiejar_to_ordered_dict(cookie_jar: CookieJar) -> Dict[str, str]: + """Convert a :class:`CookieJar` to an :class:`OrderedDict` of name→value.""" cookies: Dict[str, str] = OrderedDict() for cookie in cookie_jar: cookies[cookie.name] = cookie.value return cookies @staticmethod - def save_cookie_values(cookie_jar: CookieJar, output_path: str | Path) -> Dict[str, str]: + def save_cookie_values( + cookie_jar: CookieJar, output_path: str | Path + ) -> Dict[str, str]: + """Save cookie name→value pairs to a JSON file and return them.""" path = Path(output_path) payload = MSL_WEB.cookiejar_to_ordered_dict(cookie_jar) path.write_text(json.dumps(payload, indent=2), encoding="utf-8") @@ -447,11 +361,19 @@ class MSL_WEB: @staticmethod def save_cookiejar(cookie_jar: CookieJar, output_path: str | Path) -> None: + """Save the full cookie jar to a JSON file.""" path = Path(output_path) - path.write_text(json.dumps(MSL_WEB.cookiejar_to_list(cookie_jar), indent=2), encoding="utf-8") + path.write_text( + json.dumps(MSL_WEB.cookiejar_to_list(cookie_jar), indent=2), + encoding="utf-8", + ) @staticmethod - def load_cookiejar(session: requests.Session, source: str | Path | Iterable[Dict[str, Any]] | Dict[str, str]) -> None: + def load_cookiejar( + session: requests.Session, + source: str | Path | Iterable[Dict[str, Any]] | Dict[str, str], + ) -> None: + """Load cookies into *session* from a file path, iterable, or dict.""" if isinstance(source, (str, Path)): path = Path(source) if not path.is_file(): @@ -473,4 +395,7 @@ class MSL_WEB: secure=item.get("secure", False), expires=item.get("expires"), rest=item.get("rest", {}), - ) \ No newline at end of file + ) + + +__all__ = ["MSL_WEB", "MSLKeys"]