From 2168dbfb93b93d004d7477edc1fa9751e2fb2ee0 Mon Sep 17 00:00:00 2001 From: Pari Malam Date: Fri, 29 May 2026 17:53:25 +0800 Subject: [PATCH] released --- .gitignore | 6 + README.md | 15 +- main.py | 4492 +++++++++++++++++ modules/__init__.py | 5 + modules/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 351 bytes modules/__pycache__/config.cpython-310.pyc | Bin 0 -> 371 bytes .../__pycache__/msl_android.cpython-310.pyc | Bin 0 -> 17172 bytes modules/__pycache__/msl_ios.cpython-310.pyc | Bin 0 -> 15725 bytes modules/__pycache__/msl_mgk.cpython-310.pyc | Bin 0 -> 24205 bytes modules/__pycache__/msl_tv.cpython-310.pyc | Bin 0 -> 16536 bytes modules/__pycache__/msl_web.cpython-310.pyc | Bin 0 -> 13813 bytes modules/config.py | 6 + modules/msl_android.py | 602 +++ modules/msl_ios.py | 566 +++ modules/msl_mgk.py | 1008 ++++ modules/msl_tv.py | 599 +++ modules/msl_web.py | 476 ++ requirements.txt | 16 + 18 files changed, 7784 insertions(+), 7 deletions(-) create mode 100644 .gitignore create mode 100644 main.py create mode 100644 modules/__init__.py create mode 100644 modules/__pycache__/__init__.cpython-310.pyc create mode 100644 modules/__pycache__/config.cpython-310.pyc create mode 100644 modules/__pycache__/msl_android.cpython-310.pyc create mode 100644 modules/__pycache__/msl_ios.cpython-310.pyc create mode 100644 modules/__pycache__/msl_mgk.cpython-310.pyc create mode 100644 modules/__pycache__/msl_tv.cpython-310.pyc create mode 100644 modules/__pycache__/msl_web.cpython-310.pyc create mode 100644 modules/config.py create mode 100644 modules/msl_android.py create mode 100644 modules/msl_ios.py create mode 100644 modules/msl_mgk.py create mode 100644 modules/msl_tv.py create mode 100644 modules/msl_web.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f08501c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +devices/ +config.ini +cookies/* +venv/* +output/* +.vscode/* \ No newline at end of file diff --git a/README.md b/README.md index f40851e..0e546db 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,10 @@ pip install requests pycryptodome pywidevine jsonpickle ├── main.py # Entry point — platform router ├── config.ini # Credentials (EMAIL / PASSWORD) ├── devices/ -│ ├── changhong_androidtv_16.0.1@001_f2c92ad9_22594_l3.wvd # L3 Android/iOS WVD -│ └── hisense_smart_tv_14.0.0_af8be6ec_11233_l1.wvd # L1 TV WVD +│ ├── l3.wvd # L3 Android/iOS WVD +│ └── l1.wvd # L1 TV WVD +│ └── KpeKph MGK KpeKph platform: base64 encryption + HMAC keys (comma-separated) +│ └── ESNID # MGK platform: model-group identity string ├── modules/ │ ├── __init__.py │ ├── config.py # config.ini loader @@ -58,9 +60,7 @@ pip install requests pycryptodome pywidevine jsonpickle │ ├── msl_ios.py # MSL_IOS class │ ├── msl_tv.py # MSL_TV class │ ├── msl_web.py # MSL_WEB class -│ └── msl_mgk.py # MSL_MGK class (Model Group Key) -├── KpeKph # MGK platform: base64 encryption + HMAC keys (comma-separated) -└── ESNID # MGK platform: model-group identity string +│ └── msl_mgk.py # MSL_MGK class (Model Group Key) ``` --- @@ -85,8 +85,8 @@ Two Widevine Device (`.wvd`) files are included in the `devices/` folder: | File | Security Level | Used by | |------|---------------|---------| -| `changhong_androidtv_16.0.1@001_f2c92ad9_22594_l3.wvd` | L3 | Android, iOS | -| `hisense_smart_tv_14.0.0_af8be6ec_11233_l1.wvd` | L1 | TV, TV OTP | +| `l3.wvd` | L3 | Android, iOS | +| `l1.wvd` | L1 | TV, TV OTP | The correct WVD is selected automatically for each platform. The `--wvd` flag lets you override with a custom device file if needed. @@ -283,3 +283,4 @@ MSL key caches are reused across runs to avoid a full handshake every time. They --- **Credits:** [Hugoved](https://github.com/Hugoved) +**Big thanks to Hugov** for the foundational work on MSL (Message Security Layer) reverse engineering, and the original pywidevine implementation that made this unified handshake toolkit possible. This project builds upon years of community research into Netflix's authentication protocols. \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..65a28fb --- /dev/null +++ b/main.py @@ -0,0 +1,4492 @@ +from __future__ import annotations +import argparse, os, base64, gzip, json, logging, random, re, sys, time, uuid, zlib, requests, urllib3 +from datetime import datetime, timezone +from http.cookies import SimpleCookie +from io import BytesIO +from pathlib import Path +from typing import Any, Dict, List, Optional +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.config import setup_config + +logging.basicConfig(level=logging.INFO, format="%(name)s - %(levelname)s - %(message)s") +log = logging.getLogger("MSL HANDSHAKE") + +config = setup_config() +EMAIL = config["NETFLIX"]["EMAIL"] +PASSWORD = config["NETFLIX"]["PASSWORD"] + +# ====================================================================== +# ANDROID +# ====================================================================== + +def run_android(wvd_path: Path, + new_msl: bool = False, no_verify: bool = False): + log = logging.getLogger('ANDROID MSL') + BASE_DIR = Path(__file__).resolve().parent + OUTPUT_DIR = BASE_DIR / "output" + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + MSL_CACHE_PATH = OUTPUT_DIR / "msl_keys_cache_android.json" + AUTH_COOKIES_PATH = OUTPUT_DIR / "netflix_auth_cookies.json" + USERIDTOKEN_PATH = OUTPUT_DIR / "netflix_auth_useridtoken.json" + TOKENS_OUTPUT_PATH = OUTPUT_DIR / "netflix_auth_tokens.json" + + NETFLIX_HOME_URL = "https://www.netflix.com/" + NETFLIX_CANONICAL_URL = "https://netflix.com/" + LOGIN_URL = "https://www.netflix.com/login" + APPBOOT_URL = "https://android15.appboot.netflix.com/appboot/NFANDROID1-PRV-P-" + MSL_HANDSHAKE_ENDPOINT = "https://android.prod.ftl.netflix.com/nq/androidui/pbo_license/~1.0.0/router" + VERIFY_LOGIN_URL = "https://android.prod.ftl.netflix.com/nq/androidui/samurai/v1/config" + + USER_AGENT = "com.netflix.mediaclient/63988 (Linux; U; Android 15; en_US; SM-F711N; Build/AP3A.240905.015.A2; Cronet/143.0.7445.0)" + CLIENT_VERSION = "18.26.0" + APP_VERSION = "9.60.0" + HAWKINS_VERSION = "5.15.0" + UI_FLAVOR = "android" + OS_VERSION = "35" + FORM_FACTOR = "phone" + FEATURE_CAPABILITIES = "supportsStudioBranding" + LOCALE = "en-US" + DEVICE_MODEL = "SM-F711N" + + VERIFY_TLS = True + RESTORE_AUTH_COOKIES = False + + ESN = f"NFANDROID1-PRV-P-SAMSUSM-F711N-22594-{''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(64))}" + + REQUEST_CLIENT_CONTEXT_UNKNOWN = '{"appView":"unknown","appState":"foreground"}' + APPBOOT_REQUEST_CLIENT_CONTEXT = '{"appView":"unknown","appState":"foreground"}' + + session = requests.Session() + session.verify = VERIFY_TLS + session.headers.update( + { + "User-Agent": USER_AGENT, + "Accept": "*/*", + } + ) + + if RESTORE_AUTH_COOKIES and AUTH_COOKIES_PATH.exists(): + try: + cached_auth_cookies = json.loads(AUTH_COOKIES_PATH.read_text(encoding="utf-8")) + if isinstance(cached_auth_cookies, dict): + session.cookies.update(cached_auth_cookies) + except Exception as exc: + log.warning("Saved authentication cookies could not be restored: %s", exc) + + log.info("Initializing session") + response = session.get(NETFLIX_CANONICAL_URL, timeout=30, allow_redirects=True) + response.raise_for_status() + + response = session.get(NETFLIX_HOME_URL, timeout=30) + response.raise_for_status() + + log.info("Requesting initial nfvdid cookie") + appboot_headers = { + "Host": "android15.appboot.netflix.com", + "Connection": "keep-alive", + "X-Netflix.Request.Client.Context": APPBOOT_REQUEST_CLIENT_CONTEXT, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": USER_AGENT, + "Accept-Encoding": "gzip, deflate, br", + } + + response = session.post( + APPBOOT_URL, + params={"keyVersion": "1"}, + headers=appboot_headers, + timeout=30, + ) + + nfvdid = "" + for cookie in session.cookies: + if cookie.name == "nfvdid": + nfvdid = cookie.value + break + + if not nfvdid: + nfvdid = response.cookies.get("nfvdid", "") + + if not nfvdid: + raise RuntimeError("The initial nfvdid cookie was not returned") + + log.info("Initial nfvdid cookie obtained") + + log.info("Starting MSL Widevine exchange") + + if not wvd_path.exists(): + raise FileNotFoundError(f"Missing WVD file: {wvd_path}") + + widevine_device = WidevineDevice.load(wvd_path) + cdm = WidevineCdm.from_device(widevine_device) + + 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": APP_VERSION, + "x-netflix.clienttype": "samurai", + "x-netflix.request.client.context": REQUEST_CLIENT_CONTEXT_UNKNOWN, + "x-netflix.esnprefix": "NFANDROID1-PRV-P-", + "x-netflix.request.uuid": ( + "".join(random.choice("0123456789abcdef") for _ in range(8)) + + "-" + + "".join(random.choice("0123456789abcdef") for _ in range(4)) + + "-" + + "".join(random.choice("0123456789abcdef") for _ in range(4)) + + "-" + + "".join(random.choice("0123456789abcdef") for _ in range(4)) + + "-" + + "".join(random.choice("0123456789abcdef") for _ in range(12)) + ), + "x-netflix.androidapi": "35", + "x-netflix.deviceformfactor": "PHONE", + "x-netflix.devicememorylevel": "HIGH", + "x-netflix.request.attempt": "1", + "x-netflix.request.id": "".join(random.choice("0123456789abcdef") for _ in range(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, + }, + ) + + handshake_cookies = { + "nfvdid": nfvdid, + } + + msl_keys = MSL_ANDROID.handshake( + msl_keys_path=str(MSL_CACHE_PATH), + session=session, + sender=ESN, + cdm=cdm, + cdm_device=str(wvd_path), + new_msl=False, + cookies=handshake_cookies, + drm="widevine", + endpoint=MSL_HANDSHAKE_ENDPOINT, + headers=msl_headers, + ) + + msl_client = MSL_ANDROID( + session=session, + keys=msl_keys, + message_id=random.randint(0, 2**52), + sender=ESN, + drm="widevine", + ) + + flow_session_id = "" + for cookie in session.cookies: + if cookie.name == "nfvdid": + nfvdid = cookie.value + elif cookie.name == "flwssn": + flow_session_id = cookie.value + + log.info("MSL Widevine exchange completed") + + log.info("Loading login page to collect session cookies") + response = session.get(LOGIN_URL, timeout=30) + response.raise_for_status() + + cookie_dict = session.cookies.get_dict() + flow_session_id = cookie_dict.get("flwssn", flow_session_id) + + if not flow_session_id: + raise RuntimeError("The flwssn flow session cookie is missing before VerifyLoginMslRequest") + + if "NetflixId" not in cookie_dict or "SecureNetflixId" not in cookie_dict: + log.warning("NetflixId or SecureNetflixId cookie is missing before VerifyLoginMslRequest") + + log.info("Submitting VerifyLoginMslRequest") + + confirm_login_query = { + "api": "33", + "appType": "samurai", + "appVer": "62902", + "appVersion": "9.18.0", + "chipset": "sm8150", + "chipsetHardware": "qcom", + "clientAppState": "FOREGROUND", + "clientAppVersionState": "NORMAL", + "countryIsoCode": "US", + "ctgr": "phone", + "dbg": "false", + "deviceLocale": "en-US", + "devmod": f"samsung_{DEVICE_MODEL}", + "ffbc": "phone", + "flwssn": flow_session_id, + "installType": "regular", + "isAutomation": "false", + "isConsumptionOnly": "true", + "isNetflixPreloaded": "false", + "isPlayBillingEnabled": "true", + "isStubInSystemPartition": "false", + "lackLocale": "false", + "landingOrigin": "https://www.netflix.com", + "mId": "SAMSUSM-F711N", + "memLevel": "HIGH", + "method": "get", + "mnf": "samsung", + "model": DEVICE_MODEL, + "netflixClientPlatform": "androidNative", + "netflixId": cookie_dict.get("NetflixId", ""), + "networkType": "wifi", + "osBoard": "kona", + "osDevice": "bloom", + "osDisplay": "RP1A.200720.012", + "password": PASSWORD, + "path": '["signInVerify"]', + "pathFormat": "hierarchical", + "platform": "android", + "preloadSignupRoValue": "", + "progressive": "false", + "qlty": "hd", + "recaptchaResponseTime": 445, + "recaptchaResponseToken": "", + "responseFormat": "json", + "roBspVer": "RP1A.200720.012", + "secureNetflixId": cookie_dict.get("SecureNetflixId", ""), + "sid": "7176", + "store": "google", + "userLoginId": EMAIL, + } + + confirm_login_headers = { + "X-Netflix.Request.NqTracking": "VerifyLoginMslRequest", + "X-Netflix.Client.Request.Name": "VerifyLoginMslRequest", + "X-Netflix.Request.Client.Context": '{"appState":"foreground"}', + "X-Netflix-Esn": ESN, + "X-Netflix.EsnPrefix": "NFANDROID1-PRV-P-", + "X-Netflix.msl-header-friendly-client": "true", + "content-encoding": "msl_v1", + } + + try: + confirm_login_header, confirm_login_payload_chunks = msl_client.send_message(endpoint=VERIFY_LOGIN_URL, + params=confirm_login_query, + application_data={}, + headers=confirm_login_headers) + except Exception: + log.error("VerifyLoginMslRequest failed") + log.debug("Request URL: %s", VERIFY_LOGIN_URL) + log.debug("Request params: %s", json.dumps(confirm_login_query, indent=2)) + log.debug("Request headers: %s", json.dumps(confirm_login_headers, indent=2)) + log.debug("Session cookies: %s", json.dumps(session.cookies.get_dict(), indent=2)) + log.exception("Exception occurred") + sys.exit(1) + + if ( + isinstance(confirm_login_payload_chunks, dict) + and "errorCode" in confirm_login_payload_chunks.get("jsonGraph", {}).get("signInVerify", {}).get("value", {}).get("fields", {}) + ): + error_code = ( + confirm_login_payload_chunks.get("jsonGraph", {}) + .get("signInVerify", {}) + .get("value", {}) + .get("fields", {}) + .get("errorCode", {}) + .get("value") + ) + log.error("Login errorCode: %s", error_code) + sys.exit(1) + + if "headerdata" not in confirm_login_header: + log.critical("Missing 'headerdata' in MSL response") + sys.exit(1) + + try: + encrypted_header_b64 = confirm_login_header["headerdata"] + encryption_key_value = msl_client.keys.encryption + sign_key_value = msl_client.keys.sign + + if isinstance(encryption_key_value, str): + try: + encryption_key = bytes.fromhex(encryption_key_value) + except ValueError: + encryption_key = encryption_key_value.encode("utf-8") + else: + encryption_key = encryption_key_value + + if isinstance(sign_key_value, str): + try: + sign_key = bytes.fromhex(sign_key_value) + except ValueError: + sign_key = sign_key_value.encode("utf-8") + else: + sign_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(encrypted_header_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) + header_data = json.loads(decrypted.decode("utf-8")) + except Exception: + log.exception("Failed to decrypt MSL header") + sys.exit(1) + + tokens = header_data.get("useridtoken") + if not tokens: + log.error("Authentication failed: invalid ESN, email, or password") + sys.exit(1) + + try: + TOKENS_OUTPUT_PATH.write_text(json.dumps(header_data, indent=4), encoding="utf-8") + USERIDTOKEN_PATH.write_text(json.dumps(tokens, indent=2), encoding="utf-8") + log.info("User ID token data saved to: %s", TOKENS_OUTPUT_PATH) + log.info("User ID token saved to: %s", USERIDTOKEN_PATH) + except Exception: + log.exception("Failed to save token files") + 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) + except Exception: + log.exception("Failed to save cookies") + sys.exit(1) + + result = { + "useridtoken": tokens, + "auth_cookies": auth_cookies, + "header_data": header_data, + } + + log.info("VerifyLoginMslRequest succeeded") + print(json.dumps(result, indent=2)) + + +# ====================================================================== +# iOS +# ====================================================================== + +def run_ios(wvd_path: Path, + new_msl: bool = False, no_verify: bool = False): + from Crypto.Cipher import AES + from Crypto.Util.Padding import unpad + log = logging.getLogger('netflix_ios_login') + + from typing import Any, Dict + + + BASE_DIR = Path(__file__).resolve().parent + OUTPUT_DIR = BASE_DIR / "output" + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + MSL_CACHE_PATH = OUTPUT_DIR / "msl_keys_cache_ios.json" + AUTH_COOKIES_PATH = OUTPUT_DIR / "netflix_auth_cookies.json" + + NETFLIX_HOME_URL = "https://www.netflix.com/" + NETFLIX_CANONICAL_URL = "https://netflix.com/" + GRAPHQL_URL = "https://ios.prod.cloud.netflix.com/graphql" + LOGIN_URL = "https://www.netflix.com/login" + BROWSE_URL = "https://www.netflix.com/browse" + APPBOOT_URL = "https://ios18.appboot.netflix.com/appboot/NFANDROID1-PRV-P-" + MSL_HANDSHAKE_ENDPOINT = "https://ios.prod.ftl.netflix.com/nq/iosplatform/pbo_license/~1.0.0/router" + + USER_AGENT = "Netflix/5850 CFNetwork/3826.600.41 Darwin/24.6.0" + CLIENT_VERSION = "18.26.0" + APP_VERSION = "18.26.0" + HAWKINS_VERSION = "5.16.0" + UI_FLAVOR = "argo" + OS_VERSION = "18.6.2" + FORM_FACTOR = "phone" + FEATURE_CAPABILITIES = "supportsStudioBranding" + LOCALE = "en-US" + DEVICE_MODEL = "iPhone15,3" + + ESN = f"NFANDROID1-PRV-P-IPHONE15=3-22594-{''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(64))}" + + REQUEST_CLIENT_CONTEXT_LANDING = '{"appView":"nmLanding","appState":"foreground"}' + REQUEST_CLIENT_CONTEXT_IDENTIFIER = '{"appView":"login","appState":"foreground"}' + REQUEST_CLIENT_CONTEXT_PASSWORD = '{"appView":"passwordLogin","appState":"foreground"}' + REQUEST_CLIENT_CONTEXT_PROFILES = '{"appView":"profilesGate","appState":"foreground"}' + + APPBOOT_CLIENT_CONTEXT = '{"appState":"foreground","reason":"user-action"}' + APPBOOT_REQUEST_CLIENT_CONTEXT = '{"appView":"unknown","appState":"foreground"}' + + RECAPTCHA_SITE_KEY = "6Lf8hrcUAAAAAIpQAFW2VFjtiYnThOjZOA5xvLyR" + + QUERY_IDS = { + "MembershipStatus": {"id": "3f50f3b3-fff8-48c0-bbd3-5fa2cb04b3c1", "version": 102}, + "CLCSScreenUpdate": {"id": "1c276cdf-caef-49cf-b38e-384972c2b47e", "version": 102}, + "CLCSSendFeedback": {"id": "079b2271-196b-4edd-b65c-e9439b22e305", "version": 102}, + "CLCSInterstitialProfileGate": {"id": "b6e10c7d-0e6f-4921-83b5-177995a80d97", "version": 102}, + } + + recaptcha_token = "" + + verify_tls = True + restore_auth_cookies = False + + session = requests.Session() + session.verify = verify_tls + session.headers.update({ + "User-Agent": USER_AGENT, + "Accept": "*/*", + }) + + if restore_auth_cookies and AUTH_COOKIES_PATH.exists(): + try: + cached_auth_cookies = json.loads(AUTH_COOKIES_PATH.read_text(encoding="utf-8")) + if isinstance(cached_auth_cookies, dict): + session.cookies.update(cached_auth_cookies) + except Exception as exc: + log.warning("Saved auth cookies could not be restored: %s", exc) + + log.info("Initializing session") + response = session.get(NETFLIX_CANONICAL_URL, timeout=30, allow_redirects=True) + response.raise_for_status() + + response = session.get(NETFLIX_HOME_URL, timeout=30) + response.raise_for_status() + + log.info("Requesting initial nfvdid cookie") + appboot_request_id = "".join(random.choice("0123456789abcdef") for _ in range(32)) + + appboot_headers = { + "Host": "ios18.appboot.netflix.com", + "X-Netflix.Client.appVersion": APP_VERSION, + "Accept": "*/*", + "X-Netflix.Request.Id": appboot_request_id, + "X-Netflix.APIAction": "appboot", + "X-Netflix.Client.Context": APPBOOT_CLIENT_CONTEXT, + "X-Netflix.Client.Request.Name": "appboot", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Content-Type": "application/x-www-form-urlencoded", + "X-Netflix.Request.Attempt": "1", + "X-Netflix.Request.Client.Context": APPBOOT_REQUEST_CLIENT_CONTEXT, + "User-Agent": USER_AGENT, + } + + response = session.post( + APPBOOT_URL, + params={"keyVersion": "1"}, + headers=appboot_headers, + timeout=30, + ) + + nfvdid = "" + for cookie in session.cookies: + if cookie.name == "nfvdid": + nfvdid = cookie.value + break + + if not nfvdid: + nfvdid = response.cookies.get("nfvdid", "") + + if not nfvdid: + raise RuntimeError("The initial nfvdid cookie was not returned") + + log.info("Initial nfvdid cookie obtained") + + log.info("Starting MSL Widevine exchange") + + if not wvd_path.exists(): + raise FileNotFoundError(f"Missing WVD file: {wvd_path}") + + device = WidevineDevice.load(wvd_path) + cdm = WidevineCdm.from_device(device) + + msl_headers = MSL_IOS.build_request_headers( + request_name="mintCookies", + user_agent=USER_AGENT, + referer=None, + esn=ESN, + expiry_timeout=12750, + host="ios.prod.ftl.netflix.com", + language="en-US,en", + device_model=quote(DEVICE_MODEL, safe=""), + extra_headers={ + "Accept-Encoding": "deflate,gzip", + "Accept-Language": "en-US,en;q=0.9", + "Content-Encoding": "msl_v1", + "X-Gibbon-Cache-Control": "no-cache", + "X-AllowCompression": "true", + "X-Client-Request-Id": str(random.randint(10**17, 10**18 - 1)), + "X-DeviceModel": quote(DEVICE_MODEL, safe=""), + "x-netflix.esn": ESN, + "X-Netflix.Client.Request.Name": "mintCookies", + "X-Netflix.Request.Client.Context": '{"appView":"login","appState":"foreground"}', + }, + ) + + handshake_cookies = { + "nfvdid": nfvdid, + } + + msl_keys = MSL_IOS.handshake( + msl_keys_path=str(MSL_CACHE_PATH), + session=session, + sender=ESN, + cdm=cdm, + cdm_device=str(wvd_path), + new_msl=False, + cookies=handshake_cookies, + drm="widevine", + endpoint=MSL_HANDSHAKE_ENDPOINT, + headers=msl_headers, + ) + + msl_client = MSL_IOS( + session=session, + keys=msl_keys, + message_id=random.randint(0, pow(2, 52)), + sender=ESN, + drm="widevine", + ) + + flow_session_id = "" + for cookie in session.cookies: + if cookie.name == "nfvdid": + nfvdid = cookie.value + elif cookie.name == "flwssn": + flow_session_id = cookie.value + + log.info("MSL Widevine exchange completed") + + log.info("Submitting membership request") + operation_name = "MembershipStatus" + variables = {} + + headers = { + "Host": "ios.prod.cloud.netflix.com", + "Connection": "keep-alive", + "X-Netflix.Request.Client.Context": REQUEST_CLIENT_CONTEXT_LANDING, + "Content-Encoding": "msl_v1", + "x-netflix.context.feature-capabilities": FEATURE_CAPABILITIES, + "x-netflix.context.operation-name": operation_name, + "X-Netflix.request.expiry.timeout": "15000", + "X-Netflix.Request.Id": "".join(random.choice("0123456789abcdef") for _ in range(32)), + "x-netflix.context.hawkins-version": HAWKINS_VERSION, + "x-netflix.context.form-factor": FORM_FACTOR, + "X-Netflix.Request.Attempt": "1", + "x-netflix.request.clcs.bucket": "high", + "User-Agent": USER_AGENT, + "Accept-Language": "en-US,en;q=0.9", + "Accept": "*/*", + "Content-Type": "application/json", + "x-netflix.context.locales": LOCALE, + "x-netflix.context.os-version": OS_VERSION, + "Accept-Encoding": "gzip, deflate, br", + "x-netflix.context.app-version": APP_VERSION, + "x-netflix.context.ui-flavor": UI_FLAVOR, + } + body = { + "operationName": operation_name, + "variables": variables, + "extensions": {"persistedQuery": QUERY_IDS[operation_name]}, + } + membership_header, membership_status_response = msl_client.send_message( + endpoint=GRAPHQL_URL, + params={}, + application_data=body, + headers=headers, + ) + if isinstance(membership_status_response, dict) and "errors" in membership_status_response: + raise RuntimeError(json.dumps(membership_status_response["errors"], indent=2)) + + log.info("Loading login page") + response = session.get(LOGIN_URL, timeout=30) + 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") + + log.info("Submitting password screen update") + + session_context: Dict[str, Any] = { + "session-breadcrumbs": {"funnel_name": "loginWeb"}, + } + session_context.update({ + "login.navigationSettings": {"hideOtpToggle": True}, + }) + + full_server_state = { + "realm": "growth", + "name": "PASSWORD_LOGIN", + "clcsSessionId": clcs_session_id, + "sessionContext": session_context, + } + + full_screen_update = { + "realm": "custom", + "name": "growthLoginByPassword", + "metadata": {"recaptchaSiteKey": RECAPTCHA_SITE_KEY}, + "loggingAction": "Submitted", + "loggingCommand": "SubmitCommand", + "referrerRenditionId": rendition_id, + } + + full_variables = { + "format": "HTML", + "imageFormat": "PNG", + "locale": "en-US", + "serverState": json.dumps(full_server_state, separators=(",", ":")), + "serverScreenUpdate": json.dumps(full_screen_update, separators=(",", ":")), + "inputFields": [ + {"name": "password", "value": {"stringValue": PASSWORD}}, + {"name": "userLoginId", "value": {"stringValue": EMAIL}}, + {"name": "countryCode", "value": {"stringValue": "1"}}, + {"name": "countryIsoCode", "value": {"stringValue": "US"}}, + {"name": "recaptchaResponseTime", "value": {"intValue": 445}}, + {"name": "recaptchaResponseToken", "value": {"stringValue": recaptcha_token}}, + ], + } + try: + operation_name = "CLCSScreenUpdate" + headers = { + "Host": "ios.prod.cloud.netflix.com", + "Connection": "keep-alive", + "X-Netflix.Request.Client.Context": REQUEST_CLIENT_CONTEXT_PASSWORD, + "Content-Encoding": "msl_v1", + "x-netflix.context.feature-capabilities": FEATURE_CAPABILITIES, + "x-netflix.context.operation-name": operation_name, + "X-Netflix.request.expiry.timeout": "15000", + "X-Netflix.Request.Id": "".join(random.choice("0123456789abcdef") for _ in range(32)), + "x-netflix.context.hawkins-version": HAWKINS_VERSION, + "x-netflix.context.form-factor": FORM_FACTOR, + "X-Netflix.Request.Attempt": "1", + "x-netflix.request.clcs.bucket": "high", + "User-Agent": USER_AGENT, + "Accept-Language": "en-US,en;q=0.9", + "Accept": "*/*", + "Content-Type": "application/json", + "x-netflix.context.locales": LOCALE, + "x-netflix.context.os-version": OS_VERSION, + "Accept-Encoding": "gzip, deflate, br", + "x-netflix.context.app-version": APP_VERSION, + "x-netflix.context.ui-flavor": UI_FLAVOR, + } + + body = { + "operationName": operation_name, + "variables": full_variables, + "extensions": { + "persistedQuery": QUERY_IDS[operation_name] + }, + } + + login_header, login_response = msl_client.send_message(endpoint=GRAPHQL_URL, + params={}, + application_data=body, + headers=headers) + + auth_cookies = {} + for cookie in session.cookies: + auth_cookies[cookie.name] = cookie.value + + data = login_response.get("data", {}) if isinstance(login_response, dict) else {} + result = data.get("result", {}) if isinstance(data, dict) else {} + + status = result.get("status") + + encrypted_header_b64 = login_header.get("headerdata") + header_data = {} + + if encrypted_header_b64: + encryption_key_value = msl_client.keys.encryption + sign_key_value = msl_client.keys.sign + + if isinstance(encryption_key_value, str): + try: + encryption_key = bytes.fromhex(encryption_key_value) + except ValueError: + encryption_key = encryption_key_value.encode("utf-8") + else: + encryption_key = encryption_key_value + + if isinstance(sign_key_value, str): + try: + sign_key = bytes.fromhex(sign_key_value) + except ValueError: + sign_key = sign_key_value.encode("utf-8") + else: + sign_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(encrypted_header_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) + header_data = json.loads(decrypted.decode("utf-8")) + + except Exception: + log.exception("Failed to process the login response") + sys.exit(1) + + if status == "SUCCESS": + log.info("LOGIN SUCCESSFUL") + + try: + AUTH_COOKIES_PATH.write_text( + json.dumps(auth_cookies, indent=2), + encoding="utf-8" + ) + log.info("Authentication cookies saved") + except Exception: + log.exception("Failed to save cookies") + sys.exit(1) + + else: + log.error("LOGIN FAILED") + sys.exit(1) + + +# ====================================================================== +# TV (email/password) +# ====================================================================== + +def run_tv(wvd_path: Path, + new_msl: bool = False, no_verify: bool = False): + from Crypto.Cipher import AES + from Crypto.Util.Padding import unpad + log = logging.getLogger('netflix_tv_login') + + + + + BASE_DIR = Path(__file__).resolve().parent + OUTPUT_DIR = BASE_DIR / "output" + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + MSL_CACHE_PATH = OUTPUT_DIR / "msl_keys_cache.json" + USER_ID_TOKEN_PATH = OUTPUT_DIR / "useridtoken.json" + MSL_TRACE_PATH = OUTPUT_DIR / "msl_debug_trace.json" + NETFLIX_COOKIES_PATH = OUTPUT_DIR / "netflix_cookies.json" + LOGIN_RESPONSE_PATH = OUTPUT_DIR / "password_login_response.json" + + MSL_HANDSHAKE_ENDPOINT = "https://nrdp25.prod.ftl.netflix.com/nq/nrdjs/pbo_tokens/%5E1.0.0/router" + MSL_TV_ENDPOINT = "https://nrdp25.prod.ftl.netflix.com/nq/nrdjs/pbo_tokens/%5E1.0.0/router" + PBO_CONFIG_ENDPOINT = "https://nrdp25.prod.ftl.netflix.com/nq/nrdjs/pbo_config/%5E1.0.0/router?ab_ui_ver=darwin&nrdapp_version=2025.2.3.0" + + DEVICE_TYPE = "NFANDROID2-PRV-NVIDIASHIELDANDROIDTV2019" + DEVICE_MODEL = "NVIDIA_SHIELD Android TV" + 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" + AUI_SW_VERSION = "UI-release-20260408_44798-gibbon-sapphire-darwinql" + SDK_VERSION = "2025.2.3.0" + CLIENT_VERSION = "v3.12.55" + NETJS_VERSION = "3.0.5" + APK_VERSION = "12.1.9" + UI_SEM_VER = "44798.0.0" + ESN = f"{DEVICE_TYPE}-11233-{''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(64))}" + + IMPORTANT_COOKIE_NAMES = ( + "netflix-mfa-nonce", + "NetflixId", + "SecureNetflixId", + "nfvdid", + "gsid", + ) + + QUERY_IDS = { + "clcsLegacyMoneyballInitiateSession": {"id": "5152154d-6b61-4333-a738-92dc4ab712bd", "version": 102}, + "clcsLegacyMoneyballSubmit": {"id": "e8ef3234-6525-4975-8796-1299602e3297", "version": 102}, + "clcsScreenUpdate": {"id": "8daa70b0-fc21-4b5e-8c7e-ce0f31c8ca66", "version": 102}, + "useNavItemsQuery": {"id": "77a2fe81-a789-4b80-8c4c-0e962194cd09", "version": 102}, + } + + REQUEST_ARGS = [ + {"name": "deviceModel", "value": {"stringValue": DEVICE_MODEL}}, + {"name": "deviceName", "value": {"stringValue": DEVICE_NAME}}, + {"name": "deviceTypeOverride", "value": {"stringValue": DEVICE_TYPE}}, + {"name": "esn", "value": {"stringValue": ESN}}, + {"name": "fetchPartnerStrings", "value": {"booleanValue": False}}, + {"name": "isSuspendedMode", "value": {"booleanValue": False}}, + {"name": "nglVersion", "value": {"stringValue": "NGL_3"}}, + {"name": "resolution", "value": {"stringValue": "720p"}}, + {"name": "secureVLV", "value": {"stringValue": "true"}}, + {"name": "swVersion", "value": {"stringValue": AUI_SW_VERSION}}, + {"name": "ui_trace_tag", "value": {"stringValue": "aui-ql"}}, + {"name": "allocAutomation", "value": {"booleanValue": False}}, + {"name": "availableLocales", "value": {"stringValue": "zh,ta,ml,ko,te,gu,zh,kn,ur,ja"}}, + {"name": "suppScripts", "value": {"stringValue": "Hant,Tibt,Thai,Taml,Sinh,Orya,Mlym,Laoo,Armn,Geor,Kore,Telu,Beng,*,Hebr,Cyrl,Gujr,Hans,Deva,Guru,Cans,Ethi,Cher,Mymr,Knda,Grek,Latn,Arab,Jpan"}}, + {"name": "deviceLocale", "value": {"stringValue": "en-CA"}}, + {"name": "inAppSwVersion", "value": {"stringValue": APP_VERSION}}, + {"name": "appVersion", "value": {"stringValue": APP_VERSION}}, + {"name": "hasGooglePlayServiceOnTenfoot", "value": {"booleanValue": True}}, + {"name": "ab_ui_ver", "value": {"stringValue": "darwin"}}, + {"name": "application_name", "value": {"stringValue": "htmltvui"}}, + {"name": "application_v", "value": {"stringValue": APP_VERSION}}, + {"name": "dh", "value": {"stringValue": "720"}}, + {"name": "dw", "value": {"stringValue": "1280"}}, + {"name": "falcor_server", "value": {"stringValue": "0.1.0"}}, + {"name": "materialize", "value": {"booleanValue": True}}, + {"name": "mdxlib_version", "value": {"stringValue": SDK_VERSION}}, + {"name": "nrdapp_version", "value": {"stringValue": SDK_VERSION}}, + {"name": "nrdlib_version", "value": {"stringValue": SDK_VERSION}}, + {"name": "nrdp", "value": {"booleanValue": True}}, + {"name": "revision", "value": {"stringValue": "latest"}}, + {"name": "sdk_version", "value": {"stringValue": SDK_VERSION}}, + {"name": "sw_version", "value": {"stringValue": ANDROID_BUILD_FINGERPRINT}}, + {"name": "tag", "value": {"stringValue": "latest"}}, + {"name": "ui_sem_ver", "value": {"stringValue": UI_SEM_VER}}, + {"name": "webapiConfigAppName", "value": {"stringValue": "htmltvui"}}, + {"name": "withSize", "value": {"booleanValue": True}}, + ] + + 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"] + + PBO_COMMON = { + "sdk": SDK_VERSION, + "platform": SDK_VERSION, + "application": ANDROID_BUILD_FINGERPRINT, + "uiversion": APP_VERSION, + "uiPlatform": "tv_ui", + "clientVersion": CLIENT_VERSION, + "apkVersion": APK_VERSION, + } + + MSL_TRACE: List[Dict[str, Any]] = [] + + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + session = requests.Session() + session.verify = False + + launch_uid = str(uuid.uuid4()) + aui_referer = ( + "https://secure.netflix.com/us/tvui/aui/20260408_44798/release_v8/auiStartup.js" + f"?q=source_type%3D2%26launchUID%3D{launch_uid}&dw=1280&dh=720&dar=16_9" + "®=false&noMemberTarget=true" + ) + runtime_referer = ( + "https://secure.netflix.com/us/tvui/ql/20260407/44745/release_v8/darwinBootstrap.js" + "?startup_key=429c6159fd3e080b97d6df5bf5ce8e38b0ecc222773811cd12d8251aeea4a738" + f"&device_type={DEVICE_TYPE}" + f"&e={quote(ESN, safe='')}" + "&env=prod&fromNM=true&nm_prefetch=true&nrdapp_version=2025.2.3.0&plain=true&script_engine=v8" + f"&sessionId={uuid.uuid4()}&authType=login&authclid={uuid.uuid4()}" + f"&q=source_type%3D2%26launchUID%3D{launch_uid}%26source_type_payload%3D" + ) + + graphql_url = "https://nrdp.prod.cloud.netflix.com/graphql" + + log.info("Fetching nfvdid from Android TV config endpoint") + response = session.get( + "https://androidtv.prod.cloud.netflix.com/android/ninja/config", + params={ + "responseFormat": "json", + "progressive": "false", + "method": "get", + "routing": "redirect", + "appType": "ninja", + "mnf": "NVIDIA", + "mId": "SHIELD=ANDROID=TV", + "appVer": "23083", + "appVerName": "12.1.9 build 23083", + "api": "30", + "modelgroup": "NVIDIASHIELDANDROIDTV2019", + "oemmodel": "", + "esn": ESN, + "osBoard": "darcy", + "osDevice": "mdarcy", + "osDisplay": "RQ1A.210105.003.7825230_4040.2147", + "osFingerprint": "NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/7825230_4040.2147:user/release-keys", + "osCpu": "armeabi-v7a", + "osProduct": "mdarcy", + "validation": "ninja_6", + "ramSizeMB": "2946", + "path": ["['deviceConfig']", "['fpConfig']"], + }, + headers={ + "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 11; SHIELD Android TV Build/RQ1A.210105.003)", + "Accept": "*/*", + "X-Netflix.Client.Request.Name": "androidninjaconfig", + "X-Netflix.Request.Client.Context": '{"appState":"foreground"}', + }, + timeout=30, + ) + response.raise_for_status() + nfvdid = session.cookies.get("nfvdid") + log.info("nfvdid: %s", (nfvdid[:80] + "...") if nfvdid else "not received") + + log.info("Bootstrap AUI and pre-login pathEvaluator") + try: + session.headers.clear() + session.get( + aui_referer, + headers={ + "User-Agent": f"Netflix/{SDK_VERSION} (DEVTYPE={DEVICE_TYPE}; Milo=1.0.6315; build_number=6315; build_sha=a1b915de)", + "Accept": "application/javascript,text/javascript,application/x-javascript", + }, + timeout=30, + ) + session.get( + "https://nrdp.prod.cloud.netflix.com/healthcheck", + headers={ + "User-Agent": f"Netflix/{SDK_VERSION} (DEVTYPE={DEVICE_TYPE}; Milo=1.0.6315; build_number=6315; build_sha=a1b915de)", + "Accept": "*/*", + "x-netflix.context.sdk-version": SDK_VERSION, + "X-Netflix.request.id": "".join(random.choice("0123456789ABCDEF") for _ in range(32)), + "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": NETJS_VERSION, + "X-Netflix.request.attempt": "1", + "Referer": aui_referer, + }, + timeout=30, + ) + + params = [ + ("ab_ui_ver", "darwin"), + ("application_name", "htmltvui"), + ("application_v", APP_VERSION), + ("dh", "720"), + ("dw", "1280"), + ("falcor_server", "0.1.0"), + ("materialize", "true"), + ("mdxlib_version", SDK_VERSION), + ("nrdapp_version", SDK_VERSION), + ("nrdlib_version", SDK_VERSION), + ("nrdp", "true"), + ("revision", "latest"), + ("sdk_version", SDK_VERSION), + ("sw_version", ANDROID_BUILD_FINGERPRINT), + ("tag", "latest"), + ("ui_sem_ver", UI_SEM_VER), + ("webapiConfigAppName", "htmltvui"), + ("withSize", "true"), + ("availableLocales", "zh,ta,ml,ko,te,gu,zh,kn,ur,ja"), + ("deviceLocale", "en-CA"), + ("deviceModel", DEVICE_MODEL), + ("deviceName", DEVICE_NAME), + ("deviceTypeOverride", DEVICE_TYPE), + ("esn", ESN), + ("hasGooglePlayServiceOnTenfoot", "true"), + ("isSuspendedMode", "false"), + ("netflixClientPlatform", "tenfootMDS"), + ("nglVersion", "NGL_3"), + ("resolution", "720p"), + ("secureVLV", "true"), + ("suppScripts", "Hant,Tibt,Thai,Taml,Sinh,Orya,Mlym,Laoo,Armn,Geor,Kore,Telu,Beng,*,Hebr,Cyrl,Gujr,Hans,Deva,Guru,Cans,Ethi,Cher,Mymr,Knda,Grek,Latn,Arab,Jpan"), + ("swVersion", AUI_SW_VERSION), + ("ui_trace_tag", "aui-ql"), + ("inAppSwVersion", APP_VERSION), + ("path", '["aui",["appconfig","partnerData","requestContext","userContext"]]'), + ("path", '["aui","truths",["project.bao.ui.enabled","tvui.aui.bugsnag.enabled","tvui.aui.clcs.enabled"]]'), + ("json", "true"), + ("method", "get"), + ("seed", str(random.random())), + ] + + session.headers.clear() + session.headers.update( + { + "User-Agent": f"Netflix/{SDK_VERSION} (DEVTYPE={DEVICE_TYPE}; Milo=1.0.6315; build_number=6315; build_sha=a1b915de)", + "Accept": "*/*", + "Accept-Encoding": "deflate,gzip", + "x-netflix.context.sdk-version": SDK_VERSION, + "X-Netflix.request.id": "".join(random.choice("0123456789ABCDEF") for _ in range(32)), + "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", + "X-Netflix.Client.Request.Name": "ui/falcorUnclassified", + "X-Netflix.Request.Routing": '{"control_tag":"auinqtv","path":"/nq/aui/endpoint/%5E1.0.0-tv/pathEvaluator"}', + "x-netflix.client.last-interacted-days": "0", + "X-Netflix.Request.NonJson.Headers": "true", + "x-netflix.client.netjs.version": NETJS_VERSION, + "X-Netflix.request.attempt": "1", + "Referer": aui_referer, + } + ) + response = session.get("https://api-global.netflix.com/aui/pathEvaluator/tv/latest?" + urlencode(params, doseq=True), timeout=30) + response.raise_for_status() + log.info("AUI bootstrap OK") + except Exception as exc: + log.warning("AUI bootstrap failed: %s", exc) + + log.info("MSL handshake and mintCookies") + msl = None + try: + cached_keys = MSL_TV.load_cache_data(MSL_CACHE_PATH) + except Exception: + cached_keys = None + + try: + if cached_keys and getattr(cached_keys, "mastertoken", None) and getattr(cached_keys, "encryption", None) and getattr(cached_keys, "sign", None): + msl = MSL_TV( + session=session, + keys=cached_keys, + message_id=random.randint(0, 2**52), + sender=ESN, + user_auth=None, + drm="widevine", + ) + log.info("Using cached MSL keys") + else: + if not wvd_path.exists(): + raise FileNotFoundError(f"Missing WVD file: {wvd_path}") + device = WidevineDevice.load(wvd_path) + cdm = WidevineCdm.from_device(device) + cdm_device = str(wvd_path) + + cookies_for_handshake: Dict[str, str] = {} + if nfvdid: + cookies_for_handshake["nfvdid"] = nfvdid + + msl_headers = MSL_TV.build_request_headers( + request_name="mintCookies", + user_agent=f"Netflix/{SDK_VERSION} (DEVTYPE={DEVICE_TYPE}; Milo=1.0.6315; build_number=6315; build_sha=a1b915de)", + referer=None, + esn=ESN, + expiry_timeout=12750, + extra_headers={ + "Accept-Encoding": "deflate,gzip", + "Content-Encoding": "msl_v1", + "X-Gibbon-Cache-Control": "no-cache", + "X-AllowCompression": "true", + "X-Client-Request-Id": str(random.randint(10**17, 10**18 - 1)), + "X-DeviceModel": quote(DEVICE_MODEL, safe=""), + "x-netflix.client.nrdjs.version": CLIENT_VERSION, + "x-netflix.esn": ESN, + }, + ) + + keys = MSL_TV.handshake( + msl_keys_path=str(MSL_CACHE_PATH), + session=session, + sender=ESN, + cdm=cdm, + cdm_device=cdm_device, + new_msl=False, + cookies=cookies_for_handshake, + drm="widevine", + endpoint=MSL_HANDSHAKE_ENDPOINT, + headers=msl_headers, + ) + + if not keys or not keys.mastertoken: + raise RuntimeError("MSL handshake did not return a valid master token") + + msl = MSL_TV( + session=session, + keys=keys, + message_id=random.randint(0, 2**52), + sender=ESN, + user_auth=None, + drm="widevine", + ) + log.info("MSL handshake OK") + + if not (msl.keys and msl.keys.mastertoken and msl.keys.encryption and msl.keys.sign): + cached_keys = MSL_TV.load_cache_data(MSL_CACHE_PATH) + if cached_keys is None: + raise RuntimeError("MSL cache is empty or expired") + msl = MSL_TV( + session=msl.session, + keys=cached_keys, + message_id=random.randint(0, 2**52), + sender=ESN, + user_auth=None, + drm="widevine", + ) + + msl.session.headers.clear() + mint_headers = MSL_TV.build_request_headers( + request_name="mintCookies", + user_agent=f"Netflix/{SDK_VERSION} (DEVTYPE={DEVICE_TYPE}; Milo=1.0.6315; build_number=6315; build_sha=a1b915de)", + referer=runtime_referer, + esn=ESN, + expiry_timeout=12750, + extra_headers={ + "Accept-Encoding": "deflate,gzip", + "Content-Encoding": "msl_v1", + "X-Gibbon-Cache-Control": "no-cache", + "X-AllowCompression": "true", + "X-Client-Request-Id": str(random.randint(10**17, 10**18 - 1)), + "X-DeviceModel": quote(DEVICE_MODEL, safe=""), + "x-netflix.esn": ESN, + }, + ) + + header, payload = msl.send_message( + endpoint=MSL_TV_ENDPOINT, + params={}, + application_data={ + "version": 2, + "common": dict(PBO_COMMON), + "url": "/mintCookies", + "languages": ["en-CA"], + "params": {}, + }, + headers=mint_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" + + key_id = "" + if msl.keys.mastertoken: + token_data = json.loads(base64.b64decode(msl.keys.mastertoken["tokendata"]).decode("utf-8")) + key_id = str(token_data.get("sequencenumber", "")).encode("utf-8").hex() + + 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 text_payload is not None: + event["_text"] = text_payload + if parsed_payload is not None: + event["_payload"] = parsed_payload + MSL_TRACE.append(event) + + useridtoken = None + stack = [parsed_payload if parsed_payload is not None else 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) + + if useridtoken: + USER_ID_TOKEN_PATH.write_text(json.dumps(useridtoken, indent=2), encoding="utf-8") + log.info("useridtoken saved to %s", USER_ID_TOKEN_PATH.name) + + log.info("Cookies after mintCookies: %s", [cookie.name for cookie in session.cookies]) + except Exception as exc: + log.warning("MSL setup or mintCookies failed: %s", exc) + log.warning("Continuing with the HAR login order anyway") + + log.info("CLCS initiate session") + trace_uuid = str(uuid.uuid4()) + session.headers.clear() + session.headers.update( + { + "Language": "en-CA,en-US,en", + "User-Agent": f"Netflix/{SDK_VERSION} (DEVTYPE={DEVICE_TYPE}; Milo=1.0.6315; build_number=6315; build_sha=a1b915de)", + "Accept": "*/*", + "Accept-Language": "en-CA,en-US,en", + "Accept-Encoding": "deflate,gzip", + "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.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', + "X-Gibbon-Cache-Control": "no-cache", + "x-netflix.request.expiry.timeout": "20000", + "x-Netflix.context.app-version": UI_SEM_VER, + "x-Netflix.context.cloud-games-enabled": "false", + "X-Netflix.context.device-height": "720", + "x-Netflix.context.device-image-capability": "scalingFactor=1.0;supportedFormats=jpg,png,astc", + "x-Netflix.context.dt": "", + "x-Netflix.context.hawkins-version": "5.13.0", + "X-Netflix.context.locales": '["en-CA","en-US","en"]', + "X-Netflix.context.ui-flavor": "photon", + "X-Netflix.request.device-model": quote(DEVICE_MODEL, safe=""), + "X-Netflix.request.is-suspended": "false", + "x-netflix.request.clcs.bucket": "high", + "X-Netflix.request.toplevel.uuid": trace_uuid, + "X-Netflix.tracing.cl.userActionId": trace_uuid, + "x-netflix.client.last-interacted-days": "0", + "X-Netflix.Request.NonJson.Headers": "true", + "x-netflix.client.netjs.version": NETJS_VERSION, + "X-Netflix.request.attempt": "1", + "X-Netflix.context.operation-name": "clcsLegacyMoneyballInitiateSession", + "Referer": aui_referer, + } + ) + + 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) + if cookie_header: + headers["Cookie"] = cookie_header + + response = session.post( + graphql_url + f"?device_type={DEVICE_TYPE}&esn={quote(ESN, safe='')}&o=clcsLegacyMoneyballInitiateSession", + json={ + "extensions": {"persistedQuery": QUERY_IDS["clcsLegacyMoneyballInitiateSession"]}, + "operationName": "clcsLegacyMoneyballInitiateSession", + "variables": { + "action": "", + "flow": "tenfootSignUp", + "hasGooglePlayService": False, + "imageFormat": "ASTC", + "inputFields": [], + "legacyRequestArguments": REQUEST_ARGS, + "mode": "none", + "resolutionMode": "TV_720P", + "supportedVideoFormat": "mp4", + }, + }, + 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"])) + + response.raise_for_status() + init_data = response.json() + + 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") 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 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_session_id = flow.get("flowSessionId", "") + clcs_session_id = flow.get("clcsSessionId", "") + rendition_id = flow.get("renditionId", "") + log.info("flowSessionId: %s", flow_session_id) + log.info("clcsSessionId: %s", clcs_session_id) + log.info("initial renditionId: %s", rendition_id) + + if not flow_session_id or not clcs_session_id: + raise RuntimeError("Failed to extract flow/session IDs from initiate session response") + + log.info("Move from welcome landing into web sign-in and password 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.toplevel.uuid"] = trace_uuid + session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid + session.headers["X-Netflix.context.operation-name"] = "clcsLegacyMoneyballSubmit" + + server_state = json.dumps( + { + "realm": "moneyball", + "flow": "tenfootSignUp", + "mode": "welcomeContentLanding", + "flowSessionId": flow_session_id, + "requestArguments": REQUEST_ARGS_DICT, + "clcsSessionId": clcs_session_id, + }, + separators=(",", ":"), + ) + + 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) + if cookie_header: + headers["Cookie"] = cookie_header + + response = session.post( + graphql_url + f"?device_type={DEVICE_TYPE}&esn={quote(ESN, safe='')}&o=clcsLegacyMoneyballSubmit", + json={ + "extensions": {"persistedQuery": QUERY_IDS["clcsLegacyMoneyballSubmit"]}, + "operationName": "clcsLegacyMoneyballSubmit", + "variables": { + "action": "signInAction", + "flow": "tenfootSignUp", + "flwssn": flow_session_id, + "imageFormat": "ASTC", + "inputFields": [], + "mode": "welcomeContentLanding", + "requestArguments": REQUEST_ARGS, + "resolutionMode": "TV_720P", + "serverState": server_state, + }, + }, + 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"])) + + 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_session_id = flow_update.get("flowSessionId", flow_session_id) + clcs_session_id = flow_update.get("clcsSessionId", clcs_session_id) + rendition_id = flow_update.get("renditionId", rendition_id) + + # 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.toplevel.uuid"] = trace_uuid + session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid + session.headers["X-Netflix.context.operation-name"] = "clcsScreenUpdate" + + server_state = json.dumps( + { + "realm": "moneyball", + "flow": "tenfootSignUp", + "mode": "webSignIn", + "flowSessionId": flow_session_id, + "requestArguments": REQUEST_ARGS_DICT, + "clcsSessionId": clcs_session_id, + }, + separators=(",", ":"), + ) + + 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) + if cookie_header: + headers["Cookie"] = cookie_header + + response = session.post( + graphql_url + f"?device_type={DEVICE_TYPE}&esn={quote(ESN, safe='')}&o=clcsScreenUpdate", + json={ + "extensions": {"persistedQuery": QUERY_IDS["clcsScreenUpdate"]}, + "operationName": "clcsScreenUpdate", + "variables": { + "imageFormat": "PNG", + "inputFields": [], + "resolutionMode": "TV_720P", + "serverScreenUpdate": json.dumps( + { + "realm": "moneyball", + "action": "lrudSignInAction", + "loggingAction": "Submitted", + "loggingCommand": "SubmitCommand", + "referrerRenditionId": rendition_id, + }, + separators=(",", ":"), + ), + "serverState": server_state, + }, + }, + headers=headers, + timeout=30, + ) + 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_session_id = flow_update.get("flowSessionId", flow_session_id) + clcs_session_id = flow_update.get("clcsSessionId", clcs_session_id) + rendition_id = flow_update.get("renditionId", rendition_id) + + # 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.toplevel.uuid"] = trace_uuid + session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid + session.headers["X-Netflix.context.operation-name"] = "clcsScreenUpdate" + + server_state = json.dumps( + { + "realm": "moneyball", + "flow": "tenfootSignUp", + "mode": "enterMemberCredentials", + "flowSessionId": flow_session_id, + "requestArguments": REQUEST_ARGS_DICT, + "clcsSessionId": clcs_session_id, + }, + separators=(",", ":"), + ) + + 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) + if cookie_header: + headers["Cookie"] = cookie_header + + response = session.post( + graphql_url + f"?device_type={DEVICE_TYPE}&esn={quote(ESN, safe='')}&o=clcsScreenUpdate", + json={ + "extensions": {"persistedQuery": QUERY_IDS["clcsScreenUpdate"]}, + "operationName": "clcsScreenUpdate", + "variables": { + "imageFormat": "PNG", + "inputFields": [ + {"name": "userLoginId", "value": {"stringValue": EMAIL}}, + ], + "resolutionMode": "TV_720P", + "serverScreenUpdate": json.dumps( + { + "realm": "moneyball", + "action": "submitUserIdAction", + "loggingAction": "Submitted", + "loggingCommand": "SubmitCommand", + "referrerRenditionId": rendition_id, + }, + separators=(",", ":"), + ), + "serverState": server_state, + }, + }, + headers=headers, + timeout=30, + ) + 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_session_id = flow_update.get("flowSessionId", flow_session_id) + clcs_session_id = flow_update.get("clcsSessionId", clcs_session_id) + rendition_id = flow_update.get("renditionId", rendition_id) + + # 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.toplevel.uuid"] = trace_uuid + session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid + session.headers["X-Netflix.context.operation-name"] = "clcsScreenUpdate" + + server_state = json.dumps( + { + "realm": "moneyball", + "flow": "tenfootSignUp", + "mode": "loginLinkOption", + "flowSessionId": flow_session_id, + "requestArguments": REQUEST_ARGS_DICT, + "clcsSessionId": clcs_session_id, + }, + separators=(",", ":"), + ) + + 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) + if cookie_header: + headers["Cookie"] = cookie_header + + response = session.post( + graphql_url + f"?device_type={DEVICE_TYPE}&esn={quote(ESN, safe='')}&o=clcsScreenUpdate", + json={ + "extensions": {"persistedQuery": QUERY_IDS["clcsScreenUpdate"]}, + "operationName": "clcsScreenUpdate", + "variables": { + "imageFormat": "PNG", + "inputFields": [], + "resolutionMode": "TV_720P", + "serverScreenUpdate": json.dumps( + { + "realm": "moneyball", + "action": "usePasswordAction", + "replaceCurrentScreen": True, + "loggingAction": "Submitted", + "loggingCommand": "SubmitCommand", + "referrerRenditionId": rendition_id, + }, + separators=(",", ":"), + ), + "serverState": server_state, + }, + }, + headers=headers, + timeout=30, + ) + 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_session_id = flow_update.get("flowSessionId", flow_session_id) + clcs_session_id = flow_update.get("clcsSessionId", clcs_session_id) + rendition_id = flow_update.get("renditionId", rendition_id) + + log.info("Credential path ready, current renditionId: %s", rendition_id) + + 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.toplevel.uuid"] = trace_uuid + session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid + session.headers["X-Netflix.context.operation-name"] = "clcsScreenUpdate" + + server_state = json.dumps( + { + "realm": "moneyball", + "flow": "tenfootSignUp", + "mode": "enterMemberCredentials", + "flowSessionId": flow_session_id, + "requestArguments": REQUEST_ARGS_DICT, + "clcsSessionId": clcs_session_id, + }, + separators=(",", ":"), + ) + + 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) + if cookie_header: + headers["Cookie"] = cookie_header + + response = session.post( + graphql_url + f"?device_type={DEVICE_TYPE}&esn={quote(ESN, safe='')}&o=clcsScreenUpdate", + json={ + "extensions": {"persistedQuery": QUERY_IDS["clcsScreenUpdate"]}, + "operationName": "clcsScreenUpdate", + "variables": { + "imageFormat": "PNG", + "inputFields": [ + {"name": "userLoginId", "value": {"stringValue": EMAIL}}, + {"name": "password", "value": {"stringValue": PASSWORD}}, + ], + "resolutionMode": "TV_720P", + "serverScreenUpdate": json.dumps( + { + "realm": "moneyball", + "action": "nextAction", + "loggingAction": "Submitted", + "loggingCommand": "SubmitCommand", + "referrerRenditionId": rendition_id, + }, + separators=(",", ":"), + ), + "serverState": server_state, + }, + }, + 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"])) + + 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) + + membership = flow_result.get("membershipStatus", "") + flow_session_id = flow_result.get("flowSessionId", flow_session_id) + clcs_session_id = flow_result.get("clcsSessionId", clcs_session_id) + rendition_id = flow_result.get("renditionId", rendition_id) + log.info("Membership after credential submit: %s", membership) + + log.info("Post-login bootstrap to obtain gsid") + trace_uuid = str(uuid.uuid4()) + session.headers.clear() + session.headers.update( + { + "Language": "en-CA,en-US,en", + "User-Agent": f"Netflix/{SDK_VERSION} (DEVTYPE={DEVICE_TYPE}; Milo=1.0.6315; build_number=6315; build_sha=a1b915de)", + "Accept": "*/*", + "Accept-Language": "en-CA,en-US,en", + "Accept-Encoding": "deflate,gzip", + "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.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"browseTitles","appstate":"foreground","reason":"unknown"}', + "X-Gibbon-Cache-Control": "no-cache", + "x-netflix.request.expiry.timeout": "20000", + "x-Netflix.context.app-version": UI_SEM_VER, + "x-Netflix.context.cloud-games-enabled": "false", + "X-Netflix.context.device-height": "720", + "x-Netflix.context.device-image-capability": "scalingFactor=1.0;supportedFormats=jpg,png,astc,webp", + "x-Netflix.context.dt": "", + "x-Netflix.context.hawkins-version": "5.13.0", + "X-Netflix.context.locales": '["en-CA","en-US","en"]', + "X-Netflix.context.ui-flavor": "photon", + "X-Netflix.request.device-model": quote(DEVICE_MODEL, safe=""), + "X-Netflix.request.is-suspended": "false", + "x-netflix.request.clcs.bucket": "high", + "X-Netflix.request.toplevel.uuid": trace_uuid, + "X-Netflix.tracing.cl.userActionId": trace_uuid, + "x-netflix.client.last-interacted-days": "0", + "X-Netflix.Request.NonJson.Headers": "true", + "x-netflix.client.netjs.version": NETJS_VERSION, + "X-Netflix.request.attempt": "1", + "X-Netflix.context.operation-name": "useNavItemsQuery", + "Referer": runtime_referer, + } + ) + + 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) + if cookie_header: + headers["Cookie"] = cookie_header + + response = session.post( + graphql_url + "?o=useNavItemsQuery", + json={ + "extensions": {"persistedQuery": QUERY_IDS["useNavItemsQuery"]}, + "operationName": "useNavItemsQuery", + "query": None, + "variables": { + "artworkCapability": { + "artworkResolution": "TVUI_720P", + "deviceResolution": "TVUI_720P", + "disablePersonalization": False, + "supportsAstcFormat": True, + "useWebPForAllImages": True, + "useWebPForLargeImages": True, + } + }, + }, + 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"])) + + response.raise_for_status() + gsid = session.cookies.get("gsid") + log.info("gsid: %s", gsid if gsid else "missing") + + log.info("Post-login PBO config and token refresh") + if msl is not None and "NetflixId" in session.cookies.get_dict() and "SecureNetflixId" in session.cookies.get_dict(): + try: + if not (msl.keys and msl.keys.mastertoken and msl.keys.encryption and msl.keys.sign): + cached_keys = MSL_TV.load_cache_data(MSL_CACHE_PATH) + if cached_keys is None: + raise RuntimeError("MSL cache is empty or expired") + msl = MSL_TV( + session=msl.session, + keys=cached_keys, + message_id=random.randint(0, 2**52), + sender=ESN, + user_auth=None, + drm="widevine", + ) + + msl.session.headers.clear() + config_headers = MSL_TV.build_request_headers( + request_name="config", + user_agent=f"Netflix/{SDK_VERSION} (DEVTYPE={DEVICE_TYPE}; Milo=1.0.6315; build_number=6315; build_sha=a1b915de)", + referer=aui_referer, + esn=ESN, + expiry_timeout=12750, + extra_headers={ + "Accept-Encoding": "deflate,gzip", + "Content-Encoding": "msl_v1", + "X-Gibbon-Cache-Control": "no-cache", + "X-AllowCompression": "true", + "X-Client-Request-Id": str(random.randint(10**17, 10**18 - 1)), + "X-DeviceModel": quote(DEVICE_MODEL, safe=""), + "x-netflix.esn": ESN, + }, + ) + msl.send_message( + endpoint=PBO_CONFIG_ENDPOINT, + params={}, + application_data={"method": "config", "params": {}}, + headers=config_headers, + ) + + for request_name, route, referer_to_use in [ + ("getPartnerToken", "/getPartnerToken", aui_referer), + ("ping", "/ping", runtime_referer), + ("getPartnerToken", "/getPartnerToken", runtime_referer), + ]: + if not (msl.keys and msl.keys.mastertoken and msl.keys.encryption and msl.keys.sign): + cached_keys = MSL_TV.load_cache_data(MSL_CACHE_PATH) + if cached_keys is None: + raise RuntimeError("MSL cache is empty or expired") + msl = MSL_TV( + session=msl.session, + keys=cached_keys, + message_id=random.randint(0, 2**52), + sender=ESN, + user_auth=None, + drm="widevine", + ) + + msl.session.headers.clear() + optional_headers = MSL_TV.build_request_headers( + request_name=request_name, + user_agent=f"Netflix/{SDK_VERSION} (DEVTYPE={DEVICE_TYPE}; Milo=1.0.6315; build_number=6315; build_sha=a1b915de)", + referer=referer_to_use, + esn=ESN, + expiry_timeout=12750, + extra_headers={ + "Accept-Encoding": "deflate,gzip", + "Content-Encoding": "msl_v1", + "X-Gibbon-Cache-Control": "no-cache", + "X-AllowCompression": "true", + "X-Client-Request-Id": str(random.randint(10**17, 10**18 - 1)), + "X-DeviceModel": quote(DEVICE_MODEL, safe=""), + "x-netflix.esn": ESN, + }, + ) + + header, payload = msl.send_message( + endpoint=MSL_TV_ENDPOINT, + params={}, + application_data={ + "version": 2, + "common": dict(PBO_COMMON), + "url": route, + "languages": ["en-CA"], + "params": {}, + }, + 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" + + 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 + 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")) + tokens = header_data.get("useridtoken") + if tokens: + USER_ID_TOKEN_PATH.write_text(json.dumps(tokens, indent=2), encoding="utf-8") + log.info("useridtoken refreshed from %s", route) + except Exception: + pass + except Exception as exc: + 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 + + cookies: Dict[str, str] = {} + for cookie in session.cookies: + if cookie.name in IMPORTANT_COOKIE_NAMES and cookie.value and cookie.name not in cookies: + cookies[cookie.name] = cookie.value + + NETFLIX_COOKIES_PATH.write_text(json.dumps(cookies, indent=2), encoding="utf-8") + MSL_TRACE_PATH.write_text(json.dumps(MSL_TRACE, indent=2, ensure_ascii=False), encoding="utf-8") + + log.info("Final status") + if membership == "CURRENT_MEMBER" and "NetflixId" in cookies and "SecureNetflixId" in cookies: + log.info("LOGIN SUCCESSFUL") + log.info("NetflixId: %s", f"{cookies['NetflixId'][:80]}...") + log.info("SecureNetflixId: %s", f"{cookies['SecureNetflixId'][:80]}...") + log.info("nfvdid: %s", f"{cookies.get('nfvdid', 'N/A')[:80]}...") + log.info("netflix-mfa-nonce: %s", f"{cookies.get('netflix-mfa-nonce', 'N/A')[:80]}...") + log.info("gsid: %s", cookies.get("gsid", "N/A")) + else: + log.error("LOGIN FAILED") + exit(1) + + result = { + "cookies": cookies, + "session": session, + "flow_session_id": flow_session_id, + "clcs_session_id": clcs_session_id, + "response": login_data, + "useridtoken_path": str(USER_ID_TOKEN_PATH) if USER_ID_TOKEN_PATH.exists() else None, + "msl_trace_path": str(MSL_TRACE_PATH), + "login_response_path": str(LOGIN_RESPONSE_PATH), + } + + log.info("Cookies saved to %s", NETFLIX_COOKIES_PATH.name) + log.info("MSL decrypt trace saved to %s", MSL_TRACE_PATH.name) + log.info("Password login response saved to %s", LOGIN_RESPONSE_PATH.name) + if result["useridtoken_path"]: + log.info("useridtoken saved to %s", Path(result["useridtoken_path"]).name) + else: + log.info("useridtoken was not observed during this run") + + +# ====================================================================== +# TV OTP (pairing code) +# ====================================================================== + +def run_tv_otp(wvd_path: Path, new_msl: bool = False, no_verify: bool = False): + log = logging.getLogger('netflix_tv_login') + + + 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) + 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" + + MSL_HANDSHAKE_ENDPOINT = "https://nrdp25.prod.ftl.netflix.com/nq/nrdjs/pbo_tokens/%5E1.0.0/router" + MSL_TV_ENDPOINT = "https://nrdp25.prod.ftl.netflix.com/nq/nrdjs/pbo_tokens/%5E1.0.0/router" + PBO_CONFIG_ENDPOINT = "https://nrdp25.prod.ftl.netflix.com/nq/nrdjs/pbo_config/%5E1.0.0/router?ab_ui_ver=darwin&nrdapp_version=2025.2.3.0" + + DEVICE_TYPE = "NFANDROID2-PRV-NVIDIASHIELDANDROIDTV2019" + DEVICE_MODEL = "NVIDIA_SHIELD Android TV" + 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))}" + + IMPORTANT_COOKIE_NAMES = ("netflix-mfa-nonce", "NetflixId", "SecureNetflixId", "nfvdid", "gsid") + + QUERY_IDS = { + "clcsLegacyMoneyballInitiateSession": {"id": "5152154d-6b61-4333-a738-92dc4ab712bd", "version": 102}, + "clcsLegacyMoneyballSubmit": {"id": "e8ef3234-6525-4975-8796-1299602e3297", "version": 102}, + "clcsScreenUpdate": {"id": "8daa70b0-fc21-4b5e-8c7e-ce0f31c8ca66", "version": 102}, + "useNavItemsQuery": {"id": "77a2fe81-a789-4b80-8c4c-0e962194cd09", "version": 102}, + } + + REQUEST_ARGS = [ + {"name": "deviceModel", "value": {"stringValue": DEVICE_MODEL}}, + {"name": "deviceName", "value": {"stringValue": DEVICE_NAME}}, + {"name": "deviceTypeOverride", "value": {"stringValue": DEVICE_TYPE}}, + {"name": "esn", "value": {"stringValue": ESN}}, + {"name": "fetchPartnerStrings", "value": {"booleanValue": False}}, + {"name": "isSuspendedMode", "value": {"booleanValue": False}}, + {"name": "nglVersion", "value": {"stringValue": "NGL_3"}}, + {"name": "resolution", "value": {"stringValue": "720p"}}, + {"name": "secureVLV", "value": {"stringValue": "true"}}, + {"name": "swVersion", "value": {"stringValue": "UI-release-20260408_44798-gibbon-sapphire-darwinql"}}, + {"name": "ui_trace_tag", "value": {"stringValue": "aui-ql"}}, + {"name": "sourceType", "value": {"stringValue": "2"}}, + {"name": "allocAutomation", "value": {"booleanValue": False}}, + {"name": "availableLocales", "value": {"stringValue": "zh,ta,ml,ko,te,gu,zh,kn,ur,ja"}}, + {"name": "suppScripts", "value": {"stringValue": "Hant,Tibt,Thai,Taml,Sinh,Orya,Mlym,Laoo,Armn,Geor,Kore,Telu,Beng,*,Hebr,Cyrl,Gujr,Hans,Deva,Guru,Cans,Ethi,Cher,Mymr,Knda,Grek,Latn,Arab,Jpan"}}, + {"name": "deviceLocale", "value": {"stringValue": "en-CA"}}, + {"name": "inAppSwVersion", "value": {"stringValue": APP_VERSION}}, + {"name": "appVersion", "value": {"stringValue": APP_VERSION}}, + {"name": "hasGooglePlayServiceOnTenfoot", "value": {"booleanValue": True}}, + {"name": "ab_ui_ver", "value": {"stringValue": "darwin"}}, + {"name": "application_name", "value": {"stringValue": "htmltvui"}}, + {"name": "application_v", "value": {"stringValue": APP_VERSION}}, + {"name": "dh", "value": {"stringValue": "720"}}, + {"name": "dw", "value": {"stringValue": "1280"}}, + {"name": "falcor_server", "value": {"stringValue": "0.1.0"}}, + {"name": "materialize", "value": {"booleanValue": True}}, + {"name": "mdxlib_version", "value": {"stringValue": "2025.2.3.0"}}, + {"name": "nrdapp_version", "value": {"stringValue": "2025.2.3.0"}}, + {"name": "nrdlib_version", "value": {"stringValue": "2025.2.3.0"}}, + {"name": "nrdp", "value": {"booleanValue": True}}, + {"name": "revision", "value": {"stringValue": "latest"}}, + {"name": "sdk_version", "value": {"stringValue": "2025.2.3.0"}}, + {"name": "sw_version", "value": {"stringValue": ANDROID_BUILD_FINGERPRINT}}, + {"name": "tag", "value": {"stringValue": "latest"}}, + {"name": "ui_sem_ver", "value": {"stringValue": "44798.0.0"}}, + {"name": "webapiConfigAppName", "value": {"stringValue": "htmltvui"}}, + {"name": "withSize", "value": {"booleanValue": True}}, + ] + + HEADERS = { + "User-Agent": "Netflix/2025.2.3.0 (DEVTYPE=NFANDROID2-PRV-NVIDIASHIELDANDROIDTV2019; Milo=1.0.6315; build_number=6315; build_sha=a1b915de)", + "Accept": "*/*", + "Accept-Encoding": "deflate,gzip", + "X-Gibbon-Cache-Control": "no-cache", + "X-AllowCompression": "true", + "X-Client-Request-Id": str(random.randint(10**17, 10**18 - 1)), + "x-netflix.request.expiry.timeout": "12750", + "X-DeviceModel": quote(DEVICE_MODEL, safe=""), + "x-netflix.client.nrdjs.version": "v3.12.55", + "Content-Type": "application/json", + "Content-Encoding": "msl_v1", + "X-Netflix.Client.Request.Name": "mintCookies", + "X-Netflix.Request.NonJson.Headers": "true", + "X-Netflix.Request.Client.Context": '{"appstate":"foreground","reason":"unknown"}', + "x-netflix.client.netjs.version": "3.0.5", + "X-Netflix.request.attempt": "1", + "x-netflix.esn": ESN, + } + + PBO_COMMON = { + "sdk": "2025.2.3.0", + "platform": "2025.2.3.0", + "application": ANDROID_BUILD_FINGERPRINT, + "uiversion": APP_VERSION, + "uiPlatform": "tv_ui", + "clientVersion": "v3.12.55", + "apkVersion": "12.1.9", + } + + AUI_STARTUP_URL = ( + "https://secure.netflix.com/us/tvui/aui/20260408_44798/release_v8/auiStartup.js" + "?q=source_type%3D2%26launchUID%3D{launch_uid}&dw=1280&dh=720&dar=16_9" + "®=false&noMemberTarget=true" + ) + + ANDROID_CONFIG_URL = "https://androidtv.prod.cloud.netflix.com/android/ninja/config" + ANDROID_CONFIG_PARAMS = { + "responseFormat": "json", + "progressive": "false", + "method": "get", + "routing": "redirect", + "appType": "ninja", + "mnf": "NVIDIA", + "mId": "SHIELD=ANDROID=TV", + "appVer": "23083", + "appVerName": "12.1.9 build 23083", + "api": "30", + "modelgroup": "NVIDIASHIELDANDROIDTV2019", + "oemmodel": "", + "esn": ESN, + "osBoard": "darcy", + "osDevice": "mdarcy", + "osDisplay": "RQ1A.210105.003.7825230_4040.2147", + "osFingerprint": "NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/7825230_4040.2147:user/release-keys", + "osCpu": "armeabi-v7a", + "osProduct": "mdarcy", + "validation": "ninja_6", + "ramSizeMB": "2946", + "path": ["['deviceConfig']", "['fpConfig']"], + } + + MSL_TRACE: List[Dict[str, Any]] = [] + + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + session = requests.Session() + session.verify = False + + launch_uid = str(uuid.uuid4()) + aui_referer = AUI_STARTUP_URL.format(launch_uid=launch_uid) + runtime_referer = ( + "https://secure.netflix.com/us/tvui/ql/20260407/44745/release_v8/darwinBootstrap.js" + "?startup_key=429c6159fd3e080b97d6df5bf5ce8e38b0ecc222773811cd12d8251aeea4a738" + f"&device_type={DEVICE_TYPE}" + f"&e={quote(ESN, safe='')}" + "&env=prod&fromNM=true&nm_prefetch=true&nrdapp_version=2025.2.3.0&plain=true&script_engine=v8" + f"&sessionId={uuid.uuid4()}&authType=login&authclid={uuid.uuid4()}" + f"&q=source_type%3D2%26launchUID%3D{launch_uid}%26source_type_payload%3D" + ) + + log.info("Fetching nfvdid from Android TV config endpoint") + response = session.get( + ANDROID_CONFIG_URL, + params=ANDROID_CONFIG_PARAMS, + headers={ + "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 11; SHIELD Android TV Build/RQ1A.210105.003)", + "Accept": "*/*", + "X-Netflix.Client.Request.Name": "androidninjaconfig", + "X-Netflix.Request.Client.Context": '{"appState":"foreground"}', + }, + timeout=30, + ) + log.info("Config response HTTP %d", response.status_code) + if response.status_code != 200: + log.warning("Config request failed: %s", response.text[:500]) + nfvdid = session.cookies.get("nfvdid") + log.info("nfvdid: %s", (nfvdid[:60] + "...") if nfvdid else "not received") + + log.info("Bootstrap TV UI") + try: + session.headers.clear() + ua = HEADERS["User-Agent"] + session.get( + aui_referer, + headers={"User-Agent": ua, "Accept": "application/javascript,text/javascript,application/x-javascript"}, + timeout=30, + ) + session.get( + "https://nrdp.prod.cloud.netflix.com/healthcheck", + headers={ + "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.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", + "X-Netflix.request.attempt": "1", + "Referer": aui_referer, + }, + timeout=30, + ) + log.info("Bootstrap OK") + except Exception as exc: + log.warning("Bootstrap failed: %s", exc) + + log.info("pre-mint pathEvaluator") + try: + params = [ + ("ab_ui_ver", "darwin"), + ("application_name", "htmltvui"), + ("application_v", APP_VERSION), + ("dh", "720"), + ("dw", "1280"), + ("falcor_server", "0.1.0"), + ("materialize", "true"), + ("mdxlib_version", "2025.2.3.0"), + ("nrdapp_version", "2025.2.3.0"), + ("nrdlib_version", "2025.2.3.0"), + ("nrdp", "true"), + ("revision", "latest"), + ("sdk_version", "2025.2.3.0"), + ("sw_version", ANDROID_BUILD_FINGERPRINT), + ("tag", "latest"), + ("ui_sem_ver", "44798.0.0"), + ("webapiConfigAppName", "htmltvui"), + ("withSize", "true"), + ("availableLocales", "zh,ta,ml,ko,te,gu,zh,kn,ur,ja"), + ("deviceLocale", "en-CA"), + ("deviceModel", DEVICE_MODEL), + ("deviceName", DEVICE_NAME), + ("deviceTypeOverride", DEVICE_TYPE), + ("esn", ESN), + ("hasGooglePlayServiceOnTenfoot", "true"), + ("isSuspendedMode", "false"), + ("netflixClientPlatform", "tenfootMDS"), + ("nglVersion", "NGL_3"), + ("resolution", "720p"), + ("secureVLV", "true"), + ("suppScripts", "Hant,Tibt,Thai,Taml,Sinh,Orya,Mlym,Laoo,Armn,Geor,Kore,Telu,Beng,*,Hebr,Cyrl,Gujr,Hans,Deva,Guru,Cans,Ethi,Cher,Mymr,Knda,Grek,Latn,Arab,Jpan"), + ("swVersion", "UI-release-20260408_44798-gibbon-sapphire-darwinql"), + ("ui_trace_tag", "aui-ql"), + ("inAppSwVersion", APP_VERSION), + ] + params.extend([ + ("path", '["aui",["appconfig","partnerData","requestContext","userContext"]]'), + ("path", '["aui","truths",["project.bao.ui.enabled","tvui.aui.bugsnag.enabled","tvui.aui.clcs.enabled","tvui.aui.improvedPollingModeMismatchCheck.enabled","tvui.aui.partner.bundle.server.driven.tou.enabled","tvui.aui.partner.fullHd.enabled","tvui.aui.preApp.enabled","tvui.aui.showDeviceSupportMenu.enabled","tvui.aui.speech.enabled","tvui.aui.welcomeContentLandingPointer.enabled","tvui.gibbon.aui.enableRouteTransition.enabled","tvui.gibbon.aui.fetchAllTranslationsWithGql","tvui.gibbon.aui.fetchAllTranslationsWithGqlVerboseLogging","tvui.gibbon.aui.flushFontsOnStartup","tvui.gibbon.aui.useNetflixSans"]]'), + ("json", "true"), + ("method", "get"), + ("seed", "0.7002274648406179"), + ]) + + session.headers.clear() + session.headers.update( + { + "User-Agent": HEADERS["User-Agent"], + "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.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', + "X-Gibbon-Cache-Control": "no-cache", + "X-Netflix.request.expiry.timeout": "20000", + "X-Netflix.Client.Request.Name": "ui/falcorUnclassified", + "X-Netflix.Request.Routing": '{"control_tag":"auinqtv","path":"/nq/aui/endpoint/%5E1.0.0-tv/pathEvaluator"}', + "x-netflix.client.last-interacted-days": "0", + "X-Netflix.Request.NonJson.Headers": "true", + "x-netflix.client.netjs.version": "3.0.5", + "X-Netflix.request.attempt": "1", + "Referer": aui_referer, + } + ) + + url = "https://api-global.netflix.com/aui/pathEvaluator/tv/latest?" + urlencode(params, doseq=True) + response = session.get(url, timeout=30) + if response.status_code != 200: + raise RuntimeError(f"pathEvaluator failed: HTTP {response.status_code} {response.text[:500]}") + log.info("pre-mint pathEvaluator OK") + except Exception as exc: + log.warning("pre-mint pathEvaluator failed: %s", exc) + + log.info("MSL Widevine key exchange + mintCookies") + msl = None + try: + if not wvd_path.exists(): + raise FileNotFoundError(f"Missing WVD file: {wvd_path}") + device = WidevineDevice.load(wvd_path) + cdm = WidevineCdm.from_device(device) + cdm_device = str(wvd_path) + + cookies_for_handshake = {} + nfvdid_cookie = session.cookies.get("nfvdid") + if nfvdid_cookie: + cookies_for_handshake["nfvdid"] = nfvdid_cookie + + log.info("Performing MSL Widevine key exchange") + session.headers.clear() + + msl_headers = MSL_TV.build_request_headers( + request_name="mintCookies", + user_agent=HEADERS["User-Agent"], + referer=None, + esn=ESN, + expiry_timeout=12750, + extra_headers={ + "Accept-Encoding": "deflate,gzip", + "Content-Encoding": "msl_v1", + "X-Gibbon-Cache-Control": "no-cache", + "X-AllowCompression": "true", + "X-Client-Request-Id": str(random.randint(10**17, 10**18 - 1)), + "X-DeviceModel": quote(DEVICE_MODEL, safe=""), + "x-netflix.esn": ESN, + }, + ) + + keys = MSL_TV.handshake( + msl_keys_path=str(MSL_CACHE_PATH), + session=session, + sender=ESN, + cdm=cdm, + cdm_device=cdm_device, + new_msl=False, + cookies=cookies_for_handshake, + drm="widevine", + endpoint=MSL_HANDSHAKE_ENDPOINT, + headers=msl_headers, + ) + + if not keys or not keys.mastertoken: + raise RuntimeError("TV_MSL handshake did not return a valid master token") + + token_data = json.loads(base64.b64decode(keys.mastertoken["tokendata"]).decode("utf-8")) + log.info("Mastertoken acquired seq=%d serial=%d", token_data["sequencenumber"], token_data["serialnumber"]) + + msl = MSL_TV( + session=session, + keys=keys, + message_id=random.randint(0, 2**52), + sender=ESN, + user_auth=None, + drm="widevine", + ) + + if not (msl.keys and msl.keys.mastertoken and msl.keys.encryption and msl.keys.sign): + cached_keys = MSL_TV.load_cache_data(MSL_CACHE_PATH) + if cached_keys is None: + raise RuntimeError("MSL cache is empty or expired and the active MSL instance is unusable") + msl = MSL_TV( + session=msl.session, + keys=cached_keys, + message_id=random.randint(0, 2**52), + sender=ESN, + user_auth=None, + drm="widevine", + ) + + msl.session.headers.clear() + mint_headers = MSL_TV.build_request_headers( + request_name="mintCookies", + user_agent=HEADERS["User-Agent"], + referer=runtime_referer, + esn=ESN, + expiry_timeout=12750, + extra_headers={ + "Accept-Encoding": "deflate,gzip", + "Content-Encoding": "msl_v1", + "X-Gibbon-Cache-Control": "no-cache", + "X-AllowCompression": "true", + "X-Client-Request-Id": str(random.randint(10**17, 10**18 - 1)), + "X-DeviceModel": quote(DEVICE_MODEL, safe=""), + "x-netflix.esn": ESN, + }, + ) + + header, payload = msl.send_message( + endpoint=MSL_TV_ENDPOINT, + params={}, + application_data={ + "version": 2, + "common": dict(PBO_COMMON), + "url": "/mintCookies", + "languages": ["en-CA"], + "params": {}, + }, + headers=mint_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") + 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 = "" + 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: 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) + + if useridtoken: + USER_ID_TOKEN_PATH.write_text(json.dumps(useridtoken, indent=2), encoding="utf-8") + log.info("useridtoken saved to %s", USER_ID_TOKEN_PATH) + + MSL_TRACE.append(event) + + cookie_names = [cookie.name for cookie in msl.session.cookies] + log.info("Cookies after mintCookies: %s", cookie_names) + if "NetflixId" not in cookie_names: + log.warning("mintCookies did not return NetflixId; payload=%s", str(payload)[:500]) + + log.info("mintCookies payload type: %s", event.get("_dataType")) + log.info("NetflixId: %s", f"{session.cookies.get('NetflixId', 'N/A')[:60]}...") + log.info("SecureNetflixId: %s", f"{session.cookies.get('SecureNetflixId', 'N/A')[:60]}...") + + try: + params = [ + ("ab_ui_ver", "darwin"), + ("application_name", "htmltvui"), + ("application_v", APP_VERSION), + ("dh", "720"), + ("dw", "1280"), + ("falcor_server", "0.1.0"), + ("materialize", "true"), + ("mdxlib_version", "2025.2.3.0"), + ("nrdapp_version", "2025.2.3.0"), + ("nrdlib_version", "2025.2.3.0"), + ("nrdp", "true"), + ("revision", "latest"), + ("sdk_version", "2025.2.3.0"), + ("sw_version", ANDROID_BUILD_FINGERPRINT), + ("tag", "latest"), + ("ui_sem_ver", "44798.0.0"), + ("webapiConfigAppName", "htmltvui"), + ("withSize", "true"), + ("availableLocales", "zh,ta,ml,ko,te,gu,zh,kn,ur,ja"), + ("deviceLocale", "en-CA"), + ("deviceModel", DEVICE_MODEL), + ("deviceName", DEVICE_NAME), + ("deviceTypeOverride", DEVICE_TYPE), + ("esn", ESN), + ("hasGooglePlayServiceOnTenfoot", "true"), + ("isSuspendedMode", "false"), + ("netflixClientPlatform", "tenfootMDS"), + ("nglVersion", "NGL_3"), + ("resolution", "720p"), + ("secureVLV", "true"), + ("suppScripts", "Hant,Tibt,Thai,Taml,Sinh,Orya,Mlym,Laoo,Armn,Geor,Kore,Telu,Beng,*,Hebr,Cyrl,Gujr,Hans,Deva,Guru,Cans,Ethi,Cher,Mymr,Knda,Grek,Latn,Arab,Jpan"), + ("swVersion", "UI-release-20260408_44798-gibbon-sapphire-darwinql"), + ("ui_trace_tag", "aui-ql"), + ("inAppSwVersion", APP_VERSION), + ] + params.extend([ + ("path", '["aui","unsupportedLanguageImage"]'), + ("path", '["aui","countryProps","cross-platform-ui",["cancelBundleUponPartnerPause","preTaxDisclaimerOnPrice","show_kr_footer_disclaimer","show_paid_button_label_when_not_free","signup_tou_checkbox"]]'), + ("path", '["aui","countryProps","tvui",["shouldReorderName","showPrivacyStatementText"]]'), + ("json", "true"), + ("method", "get"), + ("seed", "0.6825750436070701"), + ]) + + session.headers.clear() + session.headers.update( + { + "User-Agent": HEADERS["User-Agent"], + "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.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', + "X-Gibbon-Cache-Control": "no-cache", + "X-Netflix.request.expiry.timeout": "20000", + "X-Netflix.Client.Request.Name": "ui/falcorUnclassified", + "X-Netflix.Request.Routing": '{"control_tag":"auinqtv","path":"/nq/aui/endpoint/%5E1.0.0-tv/pathEvaluator"}', + "x-netflix.client.last-interacted-days": "0", + "X-Netflix.Request.NonJson.Headers": "true", + "x-netflix.client.netjs.version": "3.0.5", + "X-Netflix.request.attempt": "1", + "Referer": aui_referer, + } + ) + + url = "https://api-global.netflix.com/aui/pathEvaluator/tv/latest?" + urlencode(params, doseq=True) + response = session.get(url, timeout=30) + if response.status_code != 200: + raise RuntimeError(f"pathEvaluator failed: HTTP {response.status_code} {response.text[:500]}") + log.info("post-mint pathEvaluator OK") + except Exception as exc: + log.warning("post-mint pathEvaluator failed: %s", exc) + + try: + msl.session.headers.clear() + config_headers = MSL_TV.build_request_headers( + request_name="config", + user_agent=HEADERS["User-Agent"], + referer=aui_referer, + esn=ESN, + expiry_timeout=12750, + extra_headers={ + "Accept-Encoding": "deflate,gzip", + "Content-Encoding": "msl_v1", + "X-Gibbon-Cache-Control": "no-cache", + "X-AllowCompression": "true", + "X-Client-Request-Id": str(random.randint(10**17, 10**18 - 1)), + "X-DeviceModel": quote(DEVICE_MODEL, safe=""), + "x-netflix.esn": ESN, + }, + ) + msl.send_message( + endpoint=PBO_CONFIG_ENDPOINT, + params={}, + application_data={"method": "config", "params": {}}, + headers=config_headers, + ) + log.info("pbo_config OK") + except Exception as exc: + log.warning("pbo_config failed: %s", exc) + + for request_name, route, referer_to_use in [ + ("getPartnerToken", "/getPartnerToken", aui_referer), + ("ping", "/ping", runtime_referer), + ("getPartnerToken", "/getPartnerToken", runtime_referer), + ]: + try: + if not (msl.keys and msl.keys.mastertoken and msl.keys.encryption and msl.keys.sign): + cached_keys = MSL_TV.load_cache_data(MSL_CACHE_PATH) + if cached_keys is None: + raise RuntimeError("MSL cache is empty or expired and the active MSL instance is unusable") + msl = MSL_TV( + session=msl.session, + keys=cached_keys, + message_id=random.randint(0, 2**52), + sender=ESN, + user_auth=None, + drm="widevine", + ) + + msl.session.headers.clear() + optional_headers = MSL_TV.build_request_headers( + request_name=request_name, + user_agent=HEADERS["User-Agent"], + referer=referer_to_use, + esn=ESN, + expiry_timeout=12750, + extra_headers={ + "Accept-Encoding": "deflate,gzip", + "Content-Encoding": "msl_v1", + "X-Gibbon-Cache-Control": "no-cache", + "X-AllowCompression": "true", + "X-Client-Request-Id": str(random.randint(10**17, 10**18 - 1)), + "X-DeviceModel": quote(DEVICE_MODEL, safe=""), + "x-netflix.esn": ESN, + }, + ) + + header, payload = msl.send_message( + endpoint=MSL_TV_ENDPOINT, + params={}, + application_data={ + "version": 2, + "common": dict(PBO_COMMON), + "url": route, + "languages": ["en-CA"], + "params": {}, + }, + 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") + 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 = "" + 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 = { + "_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) + + if useridtoken: + USER_ID_TOKEN_PATH.write_text(json.dumps(useridtoken, indent=2), encoding="utf-8") + log.info("useridtoken saved to %s", USER_ID_TOKEN_PATH) + + MSL_TRACE.append(event) + log.info("PBO route %s completed, payload type=%s", route, event.get("_dataType")) + except Exception as exc: + log.warning("Optional PBO route %s failed: %s", route, exc) + + except Exception as exc: + log.warning("MSL setup or mintCookies failed: %s", exc) + log.warning("Continuing without guaranteed MSL cookies") + + log.info("Initiating CLCS login session") + trace_uuid = str(uuid.uuid4()) + session.headers.clear() + session.headers.update( + { + "Language": "en-CA,en-US,en", + "User-Agent": HEADERS["User-Agent"], + "Accept": "*/*", + "Accept-Language": "en-CA,en-US,en", + "Accept-Encoding": "deflate,gzip", + "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.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', + "X-Gibbon-Cache-Control": "no-cache", + "x-netflix.request.expiry.timeout": "20000", + "x-Netflix.context.app-version": "44798.0.0", + "x-Netflix.context.cloud-games-enabled": "false", + "X-Netflix.context.device-height": "720", + "x-Netflix.context.device-image-capability": "scalingFactor=1.0;supportedFormats=jpg,png,astc", + "x-Netflix.context.dt": "", + "x-Netflix.context.hawkins-version": "5.13.0", + "X-Netflix.context.locales": '["en-CA","en-US","en"]', + "X-Netflix.context.ui-flavor": "photon", + "X-Netflix.request.device-model": quote(DEVICE_MODEL, safe=""), + "X-Netflix.request.is-suspended": "false", + "x-netflix.request.clcs.bucket": "high", + "X-Netflix.request.toplevel.uuid": trace_uuid, + "X-Netflix.tracing.cl.userActionId": trace_uuid, + "x-netflix.client.last-interacted-days": "0", + "X-Netflix.Request.NonJson.Headers": "true", + "x-netflix.client.netjs.version": "3.0.5", + "X-Netflix.request.attempt": "1", + "X-Netflix.context.operation-name": "clcsLegacyMoneyballInitiateSession", + "Referer": aui_referer, + } + ) + + cookie_values: List[str] = [] + graphql_url = ( + "https://nrdp.prod.cloud.netflix.com/graphql" + f"?device_type={DEVICE_TYPE}" + f"&esn={quote(ESN, safe='')}" + f"&o=clcsLegacyMoneyballInitiateSession" + ) + body = { + "extensions": {"persistedQuery": QUERY_IDS["clcsLegacyMoneyballInitiateSession"]}, + "operationName": "clcsLegacyMoneyballInitiateSession", + "variables": { + "action": "", + "flow": "tenfootSignUp", + "hasGooglePlayService": False, + "imageFormat": "ASTC", + "inputFields": [], + "legacyRequestArguments": REQUEST_ARGS, + "mode": "none", + "resolutionMode": "TV_720P", + "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) + 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: 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 + + 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_session_id = flow.get("flowSessionId", "") + clcs_session_id = flow.get("clcsSessionId", "") + rendition_id = flow.get("renditionId", "") + + log.info("Flow session: %s", flow_session_id) + log.info("CLCS session: %s", clcs_session_id) + log.info("Mode: %s | Status: %s", flow.get("mode"), flow.get("membershipStatus")) + + if not flow_session_id or not clcs_session_id: + raise RuntimeError(f"Failed to extract flow/session IDs from response: {json.dumps(init_data)[:800]}") + + log.info("Navigating to sign-in screen") + trace_uuid = str(uuid.uuid4()) + session.headers.clear() + session.headers.update( + { + "Language": "en-CA,en-US,en", + "User-Agent": HEADERS["User-Agent"], + "Accept": "*/*", + "Accept-Language": "en-CA,en-US,en", + "Accept-Encoding": "deflate,gzip", + "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.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', + "X-Gibbon-Cache-Control": "no-cache", + "x-netflix.request.expiry.timeout": "20000", + "x-Netflix.context.app-version": "44798.0.0", + "x-Netflix.context.cloud-games-enabled": "false", + "X-Netflix.context.device-height": "720", + "x-Netflix.context.device-image-capability": "scalingFactor=1.0;supportedFormats=jpg,png,astc", + "x-Netflix.context.dt": "", + "x-Netflix.context.hawkins-version": "5.13.0", + "X-Netflix.context.locales": '["en-CA","en-US","en"]', + "X-Netflix.context.ui-flavor": "photon", + "X-Netflix.request.device-model": quote(DEVICE_MODEL, safe=""), + "X-Netflix.request.is-suspended": "false", + "x-netflix.request.clcs.bucket": "high", + "X-Netflix.request.toplevel.uuid": trace_uuid, + "X-Netflix.tracing.cl.userActionId": trace_uuid, + "x-netflix.client.last-interacted-days": "0", + "X-Netflix.Request.NonJson.Headers": "true", + "x-netflix.client.netjs.version": "3.0.5", + "X-Netflix.request.attempt": "1", + "X-Netflix.context.operation-name": "clcsLegacyMoneyballSubmit", + "Referer": aui_referer, + } + ) + + 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"] + + server_state = json.dumps( + { + "realm": "moneyball", + "flow": "tenfootSignUp", + "mode": "welcomeContentLanding", + "flowSessionId": flow_session_id, + "requestArguments": request_args_dict, + "clcsSessionId": clcs_session_id, + }, + separators=(",", ":"), + ) + + cookie_values = [] + graphql_url = ( + "https://nrdp.prod.cloud.netflix.com/graphql" + f"?device_type={DEVICE_TYPE}" + f"&esn={quote(ESN, safe='')}" + f"&o=clcsLegacyMoneyballSubmit" + ) + body = { + "extensions": {"persistedQuery": QUERY_IDS["clcsLegacyMoneyballSubmit"]}, + "operationName": "clcsLegacyMoneyballSubmit", + "variables": { + "action": "signInAction", + "flow": "tenfootSignUp", + "flwssn": flow_session_id, + "imageFormat": "ASTC", + "inputFields": [], + "mode": "welcomeContentLanding", + "requestArguments": REQUEST_ARGS, + "resolutionMode": "TV_720P", + "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) + 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 + + 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) + + rendition_id = flow2.get("renditionId", rendition_id) + log.info("Rendition: %s", rendition_id) + + nonce = session.cookies.get("netflix-mfa-nonce") + if nonce: + log.info("netflix-mfa-nonce: %s", f"{nonce[:80]}...") + else: + log.warning("netflix-mfa-nonce was not present after signInAction") + + log.info("Using phone / TV code sign-in") + text = json.dumps(submit_data, ensure_ascii=False) + tvcode_info: Dict[str, str] = {} + + match = re.search(r'"previousRendezvousCode":"(\d+)"', text) + if match: + tvcode_info["code"] = match.group(1) + else: + match = re.search(r'(?= 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 + + response.raise_for_status() + poll_data = response.json() + last_poll_data = poll_data + + continue_action = None + stack = [poll_data] + while stack: + value = stack.pop() + if isinstance(value, dict): + server_screen_update = value.get("serverScreenUpdate") + if isinstance(server_screen_update, str) and '"action":"continueAction"' in server_screen_update: + try: + continue_action = json.loads(server_screen_update) + break + except Exception: + pass + for child in value.values(): + stack.append(child) + elif isinstance(value, list): + for item in value: + stack.append(item) + + if continue_action: + log.info("Activation detected") + break + + text = json.dumps(poll_data, ensure_ascii=False) + updated = "" + match = re.search(r'"previousRendezvousCode":"(\d+)"', text) + if match: + updated = match.group(1) + else: + match = re.search(r'(?= 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 + + 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) + + membership = flow_result.get("membershipStatus", "") + log.info("Membership: %s", membership) + + if membership != "CURRENT_MEMBER": + log.warning("Expected CURRENT_MEMBER, got: %s", membership) + errors = login_data.get("errors") + if errors: + log.warning("Structured errors: %s", json.dumps(errors, ensure_ascii=False)) + log.warning("Response: %s", json.dumps(login_data)[:1500]) + + if membership == "CURRENT_MEMBER" and session.cookies.get("NetflixId") and not session.cookies.get("gsid"): + log.info("Fetching post-login gsid cookie") + trace_uuid = str(uuid.uuid4()) + session.headers.clear() + session.headers.update( + { + "Language": "en-CA,en-US,en", + "User-Agent": HEADERS["User-Agent"], + "Accept": "*/*", + "Accept-Language": "en-CA,en-US,en", + "Accept-Encoding": "deflate,gzip", + "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.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', + "X-Gibbon-Cache-Control": "no-cache", + "x-netflix.request.expiry.timeout": "20000", + "x-Netflix.context.app-version": "44798.0.0", + "x-Netflix.context.cloud-games-enabled": "false", + "X-Netflix.context.device-height": "720", + "x-Netflix.context.device-image-capability": "scalingFactor=1.0;supportedFormats=jpg,png,astc", + "x-Netflix.context.dt": "", + "x-Netflix.context.hawkins-version": "5.13.0", + "X-Netflix.context.locales": '["en-CA","en-US","en"]', + "X-Netflix.context.ui-flavor": "photon", + "X-Netflix.request.device-model": quote(DEVICE_MODEL, safe=""), + "X-Netflix.request.is-suspended": "false", + "x-netflix.request.clcs.bucket": "high", + "X-Netflix.request.toplevel.uuid": trace_uuid, + "X-Netflix.tracing.cl.userActionId": trace_uuid, + "x-netflix.client.last-interacted-days": "0", + "X-Netflix.Request.NonJson.Headers": "true", + "x-netflix.client.netjs.version": "3.0.5", + "X-Netflix.request.attempt": "1", + "X-Netflix.context.operation-name": "useNavItemsQuery", + "Referer": runtime_referer, + } + ) + + cookie_values = [] + graphql_url = "https://nrdp.prod.cloud.netflix.com/graphql?o=useNavItemsQuery" + body = { + "extensions": {"persistedQuery": QUERY_IDS["useNavItemsQuery"]}, + "operationName": "useNavItemsQuery", + "query": None, + "variables": { + "artworkCapability": { + "artworkResolution": "TVUI_720P", + "deviceResolution": "TVUI_720P", + "disablePersonalization": False, + "supportsAstcFormat": True, + "useWebPForAllImages": True, + "useWebPForLargeImages": True, + } + }, + } + + 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) + headers = dict(session.headers) + if cookie_header: + headers["Cookie"] = cookie_header + + try: + 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"])) + + if response.ok and session.cookies.get("gsid"): + log.info("gsid: %s", f"{session.cookies.get('gsid', 'N/A')[:80]}...") + else: + log.warning("Post-login nav bootstrap did not produce gsid") + 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 + + cookies: Dict[str, str] = {} + for cookie in session.cookies: + if cookie.name in IMPORTANT_COOKIE_NAMES and cookie.value and cookie.name not in cookies: + cookies[cookie.name] = cookie.value + + if membership == "CURRENT_MEMBER" and "NetflixId" in cookies: + log.info("LOGIN SUCCESSFUL") + log.info("NetflixId: %s", f"{cookies['NetflixId'][:80]}...") + log.info("SecureNetflixId: %s", f"{cookies.get('SecureNetflixId', 'N/A')[:80]}...") + log.info("nfvdid: %s", f"{cookies.get('nfvdid', 'N/A')[:80]}...") + log.info("gsid: %s", f"{cookies.get('gsid', 'N/A')[:80]}...") + log.info("netflix-mfa-nonce: %s", f"{cookies.get('netflix-mfa-nonce', 'N/A')[:80]}...") + else: + log.error("LOGIN FAILED") + if membership != "CURRENT_MEMBER": + log.error("Reason: membership status is '%s' (expected CURRENT_MEMBER)", membership) + if "NetflixId" not in cookies: + log.error("No NetflixId cookie received") + log.error("Cookies present: %s", [cookie.name for cookie in session.cookies]) + + NETFLIX_COOKIES_PATH.write_text(json.dumps(cookies, indent=2), encoding="utf-8") + + result = { + "code": code, + "cookies": cookies, + "session": session, + "flow_session_id": flow_session_id, + "clcs_session_id": clcs_session_id, + "response": login_data, + "poll_response": last_poll_data, + "useridtoken_path": str(USER_ID_TOKEN_PATH) if USER_ID_TOKEN_PATH.exists() else None, + } + + log.info("Cookies saved to %s", NETFLIX_COOKIES_PATH.name) + if result["useridtoken_path"]: + log.info("useridtoken saved to %s", Path(result["useridtoken_path"]).name) + else: + log.info("useridtoken was not observed during this run") + + +# ====================================================================== +# WEB +# ====================================================================== + +def run_web(new_msl: bool = False, no_verify: bool = False, + recaptcha_token: str = ''): + log = logging.getLogger('netflix_web_login') + + 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) + 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" + + NETFLIX_HOME_URL = "https://www.netflix.com/" + NETFLIX_CANONICAL_URL = "https://netflix.com/" + GRAPHQL_URL = "https://web.prod.cloud.netflix.com/graphql" + LOGIN_URL = "https://www.netflix.com/login" + BROWSE_URL = "https://www.netflix.com/browse" + MSL_ALE_ENDPOINT = "https://www.netflix.com/nq/msl_v1/nrdjs/pbo_tokens/%5E1.0.0/router" + + USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/146.0.0.0 Safari/537.36" + ) + CLIENT_VERSION = "6.135.459.031" + APP_VERSION = "ve300d66c" + HAWKINS_VERSION = "5.16.0" + UI_FLAVOR = "akira" + MSL_ESN = f"NFCDCH-02-{''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(32))}" + REQUEST_CLIENT_CONTEXT = '{"appstate":"foreground"}' + RECAPTCHA_SITE_KEY = "6Lf8hrcUAAAAAIpQAFW2VFjtiYnThOjZOA5xvLyR" + + QUERY_IDS = { + "MembershipStatus": {"id": "3f50f3b3-fff8-48c0-bbd3-5fa2cb04b3c1", "version": 102}, + "CLCSScreenUpdate": {"id": "1c276cdf-caef-49cf-b38e-384972c2b47e", "version": 102}, + "CLCSSendFeedback": {"id": "079b2271-196b-4edd-b65c-e9439b22e305", "version": 102}, + "CLCSInterstitialProfileGate": {"id": "b6e10c7d-0e6f-4921-83b5-177995a80d97", "version": 102}, + } + + recaptcha_token = "" + + verify_tls = True + restore_prelogin_cookies = True + restore_auth_cookies = False + + session = requests.Session() + session.verify = verify_tls + session.headers.update({ + "User-Agent": USER_AGENT, + "Accept": "*/*", + }) + + if restore_auth_cookies: + MSL_WEB.load_cookiejar(session, AUTH_COOKIES_PATH) + elif restore_prelogin_cookies: + MSL_WEB.load_cookiejar(session, PRELOGIN_COOKIES_PATH) + + log.info("Bootstrapping anonymous browser session") + response = session.get(NETFLIX_CANONICAL_URL, timeout=30, allow_redirects=True) + response.raise_for_status() + + home_response = session.get(NETFLIX_HOME_URL, timeout=30) + home_response.raise_for_status() + + log.info("Sending MembershipStatus probe") + operation_name = "MembershipStatus" + variables = {} + referer = NETFLIX_HOME_URL + originating_url = NETFLIX_HOME_URL + + headers = { + "Host": "web.prod.cloud.netflix.com", + "Connection": "keep-alive", + "x-netflix.request.id": "".join(random.choice("0123456789abcdef") for _ in range(32)), + "x-netflix.context.operation-name": operation_name, + "x-netflix.request.originating.url": originating_url, + "x-netflix.context.app-version": APP_VERSION, + "x-netflix.context.hawkins-version": HAWKINS_VERSION, + "x-netflix.context.locales": "en-us", + "x-netflix.context.ui-flavor": UI_FLAVOR, + "x-netflix.request.toplevel.uuid": str(uuid.uuid4()), + "x-netflix.request.attempt": "1", + "x-netflix.request.client.context": REQUEST_CLIENT_CONTEXT, + "x-netflix.request.clcs.bucket": "high", + "User-Agent": USER_AGENT, + "Accept": "application/json", + "Content-Type": "application/json", + "Origin": "https://www.netflix.com", + "Referer": referer, + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", + "x-netflix.request.client.version": CLIENT_VERSION, + "x-netflix.request.client.id": "ui/akiraWeb", + } + body = { + "operationName": operation_name, + "variables": variables, + "extensions": {"persistedQuery": QUERY_IDS[operation_name]}, + } + response = session.post(GRAPHQL_URL, json=body, headers=headers, timeout=30) + response.raise_for_status() + membership_status_response = response.json() + if "errors" in membership_status_response: + raise RuntimeError(json.dumps(membership_status_response["errors"], indent=2)) + + log.info("Fetching login page and extracting screen context") + response = session.get(LOGIN_URL, timeout=30) + 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") + + screen_name = "IDENTIFICATION" + screen_name = "PASSWORD_LOGIN" + + log.info("Submitting password step directly to PASSWORD_LOGIN") + + session_context: Dict[str, Any] = { + "session-breadcrumbs": {"funnel_name": "loginWeb"}, + } + session_context.update({ + "login.navigationSettings": {"hideOtpToggle": True}, + }) + full_server_state = { + "realm": "growth", + "name": "PASSWORD_LOGIN", + "clcsSessionId": clcs_session_id, + "sessionContext": session_context, + } + + full_screen_update = { + "realm": "custom", + "name": "growthLoginByPassword", + "metadata": {"recaptchaSiteKey": RECAPTCHA_SITE_KEY}, + "loggingAction": "Submitted", + "loggingCommand": "SubmitCommand", + "referrerRenditionId": rendition_id, + } + + full_variables = { + "format": "HTML", + "imageFormat": "PNG", + "locale": "en-US", + "serverState": json.dumps(full_server_state, separators=(",", ":")), + "serverScreenUpdate": json.dumps(full_screen_update, separators=(",", ":")), + "inputFields": [ + {"name": "password", "value": {"stringValue": PASSWORD}}, + {"name": "userLoginId", "value": {"stringValue": EMAIL}}, + {"name": "countryCode", "value": {"stringValue": "1"}}, + {"name": "countryIsoCode", "value": {"stringValue": "US"}}, + {"name": "recaptchaResponseTime", "value": {"intValue": 445}}, + {"name": "recaptchaResponseToken", "value": {"stringValue": recaptcha_token}}, + ], + } + + try: + operation_name = "CLCSScreenUpdate" + referer = NETFLIX_HOME_URL + originating_url = f"{LOGIN_URL}?serverState={quote(json.dumps(full_server_state, separators=(',', ':')))}" + + headers = { + "Host": "web.prod.cloud.netflix.com", + "Connection": "keep-alive", + "x-netflix.request.id": "".join(random.choice("0123456789abcdef") for _ in range(32)), + "x-netflix.context.operation-name": operation_name, + "x-netflix.request.originating.url": originating_url, + "x-netflix.context.app-version": APP_VERSION, + "x-netflix.context.hawkins-version": HAWKINS_VERSION, + "x-netflix.context.locales": "en-us", + "x-netflix.context.ui-flavor": UI_FLAVOR, + "x-netflix.request.toplevel.uuid": str(uuid.uuid4()), + "x-netflix.request.attempt": "1", + "x-netflix.request.client.context": REQUEST_CLIENT_CONTEXT, + "x-netflix.request.clcs.bucket": "high", + "User-Agent": USER_AGENT, + "Accept": "application/json", + "Content-Type": "application/json", + "Origin": "https://www.netflix.com", + "Referer": referer, + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", + "x-netflix.request.client.version": CLIENT_VERSION, + "x-netflix.request.client.id": "ui/akiraWeb", + } + body = { + "operationName": operation_name, + "variables": full_variables, + "extensions": {"persistedQuery": QUERY_IDS[operation_name]}, + } + response = session.post(GRAPHQL_URL, json=body, headers=headers, timeout=30) + response.raise_for_status() + login_response = response.json() + if "errors" in login_response: + raise RuntimeError(json.dumps(login_response["errors"], indent=2)) + + except Exception as exc: + log.warning("Full PASSWORD_LOGIN submit failed, retrying with minimal payload: %s", exc) + + session_context = { + "session-breadcrumbs": {"funnel_name": "loginWeb"}, + } + minimal_server_state = { + "realm": "growth", + "name": "PASSWORD_LOGIN", + "clcsSessionId": clcs_session_id, + "sessionContext": session_context, + } + + minimal_screen_update = { + "realm": "custom", + "name": "growthLoginByPassword", + } + + minimal_variables = { + "format": "HTML", + "imageFormat": "PNG", + "locale": "en-US", + "serverState": json.dumps(minimal_server_state, separators=(",", ":")), + "serverScreenUpdate": json.dumps(minimal_screen_update, separators=(",", ":")), + "inputFields": [ + {"name": "userLoginId", "value": {"stringValue": EMAIL}}, + {"name": "password", "value": {"stringValue": PASSWORD}}, + ], + } + + operation_name = "CLCSScreenUpdate" + referer = NETFLIX_HOME_URL + originating_url = f"{LOGIN_URL}?serverState={quote(json.dumps(minimal_server_state, separators=(',', ':')))}" + + headers = { + "Host": "web.prod.cloud.netflix.com", + "Connection": "keep-alive", + "x-netflix.request.id": "".join(random.choice("0123456789abcdef") for _ in range(32)), + "x-netflix.context.operation-name": operation_name, + "x-netflix.request.originating.url": originating_url, + "x-netflix.context.app-version": APP_VERSION, + "x-netflix.context.hawkins-version": HAWKINS_VERSION, + "x-netflix.context.locales": "en-us", + "x-netflix.context.ui-flavor": UI_FLAVOR, + "x-netflix.request.toplevel.uuid": str(uuid.uuid4()), + "x-netflix.request.attempt": "1", + "x-netflix.request.client.context": REQUEST_CLIENT_CONTEXT, + "x-netflix.request.clcs.bucket": "high", + "User-Agent": USER_AGENT, + "Accept": "application/json", + "Content-Type": "application/json", + "Origin": "https://www.netflix.com", + "Referer": referer, + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", + "x-netflix.request.client.version": CLIENT_VERSION, + "x-netflix.request.client.id": "ui/akiraWeb", + } + body = { + "operationName": operation_name, + "variables": minimal_variables, + "extensions": {"persistedQuery": QUERY_IDS[operation_name]}, + } + response = session.post(GRAPHQL_URL, json=body, headers=headers, timeout=30) + response.raise_for_status() + login_response = response.json() + if "errors" in login_response: + raise RuntimeError(json.dumps(login_response["errors"], indent=2)) + + response_text = json.dumps(login_response, ensure_ascii=False) + screen_names = re.findall(r'"name":"([A-Z_]+)"', response_text) + rendition_ids = re.findall(r'"renditionId":"([0-9a-f\-]{36})"', response_text) + clcs_match = re.search(r'"clcsSessionId":"([0-9a-f\-]{36})"', response_text) + + next_clcs_session_id = clcs_match.group(1) if clcs_match else clcs_session_id + next_screen_name = screen_names[-1] if screen_names else "PASSWORD_LOGIN" + next_rendition_id = rendition_ids[-1] if rendition_ids else rendition_id + + feedback_payload = None + effect = login_response.get("data", {}).get("result", {}).get("effect", {}) + nodes = effect.get("nodes", []) if isinstance(effect, dict) else [] + for node in nodes: + if node.get("__typename") == "CLCSSendFeedback" and node.get("serverFeedback"): + feedback_payload = json.loads(node["serverFeedback"]) + break + + if feedback_payload: + log.info("Sending CLCSSendFeedback after successful login") + + session_context = { + "session-breadcrumbs": {"funnel_name": "loginWeb"}, + } + session_context.update({ + "login.navigationSettings": {"hideOtpToggle": True}, + }) + feedback_server_state = { + "realm": "growth", + "name": "PASSWORD_LOGIN", + "clcsSessionId": next_clcs_session_id, + "sessionContext": session_context, + } + + operation_name = "CLCSSendFeedback" + feedback_variables = { + "inputFields": [], + "serverFeedback": json.dumps(feedback_payload, separators=(",", ":")), + "serverState": json.dumps(feedback_server_state, separators=(",", ":")), + } + referer = NETFLIX_HOME_URL + originating_url = f"{LOGIN_URL}?serverState={quote(json.dumps(feedback_server_state, separators=(',', ':')))}" + + headers = { + "Host": "web.prod.cloud.netflix.com", + "Connection": "keep-alive", + "x-netflix.request.id": "".join(random.choice("0123456789abcdef") for _ in range(32)), + "x-netflix.context.operation-name": operation_name, + "x-netflix.request.originating.url": originating_url, + "x-netflix.context.app-version": APP_VERSION, + "x-netflix.context.hawkins-version": HAWKINS_VERSION, + "x-netflix.context.locales": "en-us", + "x-netflix.context.ui-flavor": UI_FLAVOR, + "x-netflix.request.toplevel.uuid": str(uuid.uuid4()), + "x-netflix.request.attempt": "1", + "x-netflix.request.client.context": REQUEST_CLIENT_CONTEXT, + "x-netflix.request.clcs.bucket": "high", + "User-Agent": USER_AGENT, + "Accept": "application/json", + "Content-Type": "application/json", + "Origin": "https://www.netflix.com", + "Referer": referer, + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", + "x-netflix.request.client.version": CLIENT_VERSION, + "x-netflix.request.client.id": "ui/akiraWeb", + } + body = { + "operationName": operation_name, + "variables": feedback_variables, + "extensions": {"persistedQuery": QUERY_IDS[operation_name]}, + } + response = session.post(GRAPHQL_URL, json=body, headers=headers, timeout=30) + response.raise_for_status() + feedback_response = response.json() + if "errors" in feedback_response: + raise RuntimeError(json.dumps(feedback_response["errors"], indent=2)) + else: + log.info("No post-login feedback payload was found") + + log.info("Opening /browse to finalize the authenticated web session") + session_context = { + "session-breadcrumbs": {"funnel_name": "loginWeb"}, + } + session_context.update({ + "login.navigationSettings": {"hideOtpToggle": True}, + }) + browse_server_state = { + "realm": "growth", + "name": "PASSWORD_LOGIN", + "clcsSessionId": next_clcs_session_id, + "sessionContext": session_context, + } + browse_originating_url = f"{LOGIN_URL}?serverState={quote(json.dumps(browse_server_state, separators=(',', ':')))}" + + response = session.get( + BROWSE_URL, + headers={ + "Referer": browse_originating_url, + "Accept-Language": "en-US,en;q=0.9", + }, + timeout=30, + ) + response.raise_for_status() + + log.info("Probing the post-login profile gate") + profile_gate_response = None + try: + operation_name = "CLCSInterstitialProfileGate" + variables = {"format": "HTML", "resolutionMode": "WEB_1X"} + referer = NETFLIX_HOME_URL + originating_url = BROWSE_URL + + headers = { + "Host": "web.prod.cloud.netflix.com", + "Connection": "keep-alive", + "x-netflix.request.id": "".join(random.choice("0123456789abcdef") for _ in range(32)), + "x-netflix.context.operation-name": operation_name, + "x-netflix.request.originating.url": originating_url, + "x-netflix.context.app-version": APP_VERSION, + "x-netflix.context.hawkins-version": HAWKINS_VERSION, + "x-netflix.context.locales": "en-us", + "x-netflix.context.ui-flavor": UI_FLAVOR, + "x-netflix.request.toplevel.uuid": str(uuid.uuid4()), + "x-netflix.request.attempt": "1", + "x-netflix.request.client.context": REQUEST_CLIENT_CONTEXT, + "x-netflix.request.clcs.bucket": "high", + "User-Agent": USER_AGENT, + "Accept": "application/json", + "Content-Type": "application/json", + "Origin": "https://www.netflix.com", + "Referer": referer, + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", + "x-netflix.request.client.version": CLIENT_VERSION, + "x-netflix.request.client.id": "ui/akiraWeb", + } + body = { + "operationName": operation_name, + "variables": variables, + "extensions": {"persistedQuery": QUERY_IDS[operation_name]}, + } + response = session.post(GRAPHQL_URL, json=body, headers=headers, timeout=30) + response.raise_for_status() + profile_gate_response = response.json() + if "errors" in profile_gate_response: + raise RuntimeError(json.dumps(profile_gate_response["errors"], indent=2)) + except Exception as exc: + log.warning("Profile gate probe failed: %s", exc) + + log.info("Attempting post-login ALE provision") + ale_response = None + try: + final_cookies = MSL_WEB.cookiejar_to_ordered_dict(session.cookies) + + req_id = "".join(random.choice("0123456789abcdef") for _ in range(32)) + endpoint = ( + f"{MSL_ALE_ENDPOINT}?reqAttempt=1&reqName=aleProvision&reqId={req_id}" + f"&clienttype={UI_FLAVOR}&uiversion={APP_VERSION}&browsername=chrome" + f"&browserversion=146.0.0.0&osname=windows&osversion=10.0" + ) + + headers = MSL_WEB.build_request_headers( + request_name="aleProvision", + user_agent=USER_AGENT, + referer=BROWSE_URL, + esn=MSL_ESN, + extra_headers={ + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", + }, + ) + + ale_response = MSL_WEB.handshake( + msl_keys_path=MSL_CACHE_PATH, + session=session, + sender=MSL_ESN, + new_msl=False, + cookies=final_cookies, + endpoint=endpoint, + headers=headers, + ) + except Exception as exc: + log.error("ALE provision failed: %s", exc) + + auth_cookies = MSL_WEB.cookiejar_to_ordered_dict(session.cookies) + AUTH_COOKIES_PATH.write_text(json.dumps(auth_cookies, indent=2), encoding="utf-8") + + result = { + "auth_cookies": auth_cookies, + } + + print(json.dumps(result, indent=2)) + + +# ====================================================================== +# MGK (Model Group Key) +# ====================================================================== + +def run_mgk(kpekph_path: Optional[Path], esnid: str, + new_msl: bool = False): + log = logging.getLogger('netflix_mgk_login') + + BASE_DIR = Path(__file__).resolve().parent + OUTPUT_DIR = BASE_DIR / "output" + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + MSL_CACHE_PATH = OUTPUT_DIR / "msl_keys_cache_mgk.json" + AUTH_COOKIES_PATH = OUTPUT_DIR / "netflix_auth_cookies_mgk.json" + + DEVICE_TYPE = "NFANDROID2-PRV-NVIDIASHIELDANDROIDTV2019" + ESN = esnid + + session = requests.Session() + session.verify = True + session.headers.update({ + "User-Agent": MSL_MGK.DEFAULT_USER_AGENT, + "Accept": "*/*", + }) + + log.info("Starting MSL MGK handshake with ESN: %s", ESN) + + handshake_headers = MSL_MGK.build_request_headers( + request_name="mintCookies", + esn=ESN, + expiry_timeout=12750, + ) + + msl_client = MSL_MGK.handshake( + session=session, + sender=ESN, + kpekph_path=str(kpekph_path) if kpekph_path else None, + msl_keys_path=str(MSL_CACHE_PATH), + cookies=None, + headers=handshake_headers, + new_msl=new_msl, + ) + + log.info("MGK handshake completed successfully") + + user_auth = UserAuthentication.EmailPassword(EMAIL, PASSWORD).__dict__ + + manifest_endpoint, manifest_params = MSL_MGK.manifest_request_defaults() + manifest_headers = MSL_MGK.build_request_headers( + request_name="licensedManifest", + esn=ESN, + expiry_timeout=12750, + ) + + log.info("Sending authenticated MSL request with EMAIL_PASSWORD user auth") + try: + header, payload = msl_client.send_message( + endpoint=manifest_endpoint, + params=manifest_params, + application_data={}, + userauthdata=user_auth, + headers=manifest_headers, + ) + except Exception: + 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) + except Exception: + log.exception("Failed to save cookies") + sys.exit(1) + + result = { + "auth_cookies": auth_cookies, + "header": header, + "payload": payload, + } + + log.info("MGK login succeeded") + print(json.dumps(result, indent=2, default=str)) + + + + +# =========================================================================== +# ENTRY POINT +# =========================================================================== + +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("--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)") + parser.add_argument("--new-msl", action="store_true", help="Force new MSL key exchange") + parser.add_argument("--no-verify", action="store_true", help="Skip TLS verification") + + args = parser.parse_args() + + if args.platform == "android": + if not args.wvd: + parser.error("--wvd is required for android platform") + run_android(wvd_path=args.wvd, new_msl=args.new_msl, no_verify=args.no_verify) + + elif args.platform == "ios": + if not args.wvd: + parser.error("--wvd is required for ios platform") + run_ios(wvd_path=args.wvd, new_msl=args.new_msl, no_verify=args.no_verify) + + elif args.platform == "tv": + if not args.wvd: + parser.error("--wvd is required for tv platform") + run_tv(wvd_path=args.wvd, new_msl=args.new_msl, no_verify=args.no_verify) + + elif args.platform == "tv_otp": + if not args.wvd: + parser.error("--wvd is required for tv_otp platform") + run_tv_otp(wvd_path=args.wvd, new_msl=args.new_msl, no_verify=args.no_verify) + + elif args.platform == "web": + run_web(new_msl=args.new_msl, no_verify=args.no_verify) + + elif args.platform == "mgk": + if not args.esnid: + parser.error("--esnid is required for mgk platform") + run_mgk(kpekph_path=args.kpekph, esnid=args.esnid, new_msl=args.new_msl) + + +if __name__ == "__main__": + if os.name == "nt": + os.system('cls') + else: + os.system('clear') + main() diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..d890222 --- /dev/null +++ b/modules/__init__.py @@ -0,0 +1,5 @@ +from .msl_android import MSL_ANDROID +from .msl_ios import MSL_IOS +from .msl_tv import MSL_TV +from .msl_web import MSL_WEB +from .msl_mgk import MSL_MGK diff --git a/modules/__pycache__/__init__.cpython-310.pyc b/modules/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1591cec824eda61002f1cc7ba991d62fcc1f7f46 GIT binary patch literal 351 zcmd1j<>g{vU|={9A(=IYfq~&Mh=Yuo7#J8F7#J9eK|~5e3S$mKE@KoUBSQ*93R4bK zE^`z!n9ZESlFJ&!3TCt9u;sExv4h#HIUKp1QJjnnDQv+En(Qwb85kHe8E2K&T2 z`nd%8d%9>c-eL!FJ^h0<8E>(Hm?2>>!Eje67}M9?+fS1#iaWPBCq6MRr6@l$r3mD_ zD0YxYW`1!IGXn!d6dQfQ;R{#G=fY0N0>ke?Lbb-GCr}FIVS~n6MDt;1EZ5Pe1pV?3mpAl+v8k;+XjO%)HE! c_;|g7%3B;Z5K%jj*NQ=*$-}_I2!aBP0L4~OzW@LL literal 0 HcmV?d00001 diff --git a/modules/__pycache__/config.cpython-310.pyc b/modules/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d2ba368a8b6527a8a826a7944076bbf647ad9358 GIT binary patch literal 371 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 Zn1_*vQGihfOoG@vj3SJDOaiPz`~b6#d&>X- literal 0 HcmV?d00001 diff --git a/modules/__pycache__/msl_ios.cpython-310.pyc b/modules/__pycache__/msl_ios.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6c2d322602db8c949220918daac509cf88c97155 GIT binary patch literal 15725 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 literal 0 HcmV?d00001 diff --git a/modules/__pycache__/msl_mgk.cpython-310.pyc b/modules/__pycache__/msl_mgk.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..460d6e09f9ba1bc5cf6d2d5593a0c817657be764 GIT binary patch literal 24205 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 literal 0 HcmV?d00001 diff --git a/modules/__pycache__/msl_tv.cpython-310.pyc b/modules/__pycache__/msl_tv.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..37db4150fb397c12877dd0ac188a1c54d02fd54a GIT binary patch literal 16536 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 literal 0 HcmV?d00001 diff --git a/modules/__pycache__/msl_web.cpython-310.pyc b/modules/__pycache__/msl_web.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..24ae084ac780ac7f74663b219ecd5cd040f1b2fb GIT binary patch literal 13813 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 T$ipbW2;=cE@-Yjr3h@B|JUvYa literal 0 HcmV?d00001 diff --git a/modules/config.py b/modules/config.py new file mode 100644 index 0000000..cff2bf8 --- /dev/null +++ b/modules/config.py @@ -0,0 +1,6 @@ +from configparser import ConfigParser + +def setup_config(config_file='config.ini'): + config = ConfigParser() + config.read(config_file) + return config \ No newline at end of file diff --git a/modules/msl_android.py b/modules/msl_android.py new file mode 100644 index 0000000..8fac929 --- /dev/null +++ b/modules/msl_android.py @@ -0,0 +1,602 @@ +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 +from Cryptodome.Hash import HMAC, SHA256 +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)}>" + + +class MSLKeys(MSLObject): + def __init__( + self, + encryption: Optional[bytes] = None, + sign: Optional[bytes] = None, + rsa: Optional[RsaKey] = None, + mastertoken: Optional[dict] = None, + cdm_session: Any = None, + ): + self.encryption = encryption + self.sign = sign + 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 = { + "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 = { + "sdk": "18.26.0", + "platform": "18.26.0", + "application": "Netflix Android 18.26.0", + "uiversion": "18.26.0", + "uiPlatform": "android", + "clientVersion": "18.26.0", + "apkVersion": "18.26.0", + } + DEFAULT_PBO_LANGUAGES = ["en-US", "en"] + DEFAULT_DEVICE_MODEL = "SM-F711N" + + def __init__( + self, + session: requests.Session, + keys: MSLKeys, + message_id: int, + sender: str, + user_auth: Optional[dict] = None, + drm: str = "widevine", + ): + self.session = session + self.keys = keys + self.sender = sender + self.user_auth = user_auth + self.message_id = message_id + self.drm = drm + + @classmethod + def handshake( + cls, + msl_keys_path: str, + session: requests.Session, + sender: str, + cdm: Any, + cdm_device: Any, + new_msl: bool, + cookies: Optional[Dict[str, str]], + drm: str, + endpoint: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + ) -> MSLKeys: + if cookies: + session.cookies.update(cookies) + + cache_path = Path(msl_keys_path) + msl_keys = MSL_ANDROID.load_cache_data(cache_path) + if msl_keys is not None and not new_msl: + return msl_keys + + if drm != "widevine": + raise ValueError(f"Unsupported DRM mode: {drm}") + + if not cdm: + raise ValueError("Widevine CDM is required for this iOS MSL flow") + + message_id = random.randint(0, pow(2, 52)) + msl_keys = MSLKeys() + + 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), + ) + wv_request = base64.b64encode(challenge).decode("utf-8") + keyrequestdata = { + "scheme": "WIDEVINE", + "keydata": { + "keyrequest": wv_request, + }, + } + + data = jsonpickle.encode( + { + "entityauthdata": { + "scheme": "NONE", + "authdata": { + "identity": sender, + }, + }, + "headerdata": base64.standard_b64encode( + MSL_ANDROID.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"Key exchange failed: HTTP {res.status_code} {res.text[:500]}") + + parsed = cls.parse_concatenated_json(res.text) + if not parsed: + raise RuntimeError("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") + 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]}") + + 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"] + + 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( + kid=base64.standard_b64decode(key_data["encryptionkeyid"]), + keys=keys, + permissions=["AllowEncrypt", "AllowDecrypt"], + ) + msl_keys.sign = MSL_ANDROID.get_widevine_key( + kid=base64.standard_b64decode(key_data["hmackeyid"]), + keys=keys, + permissions=["AllowSign", "AllowSignatureVerify"], + ) + + msl_keys.mastertoken = key_response_data["mastertoken"] + MSL_ANDROID.cache_keys(msl_keys, cache_path) + return msl_keys + + @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, + host: Optional[str] = "android15.prod.cloud.netflix.com", + language: Optional[str] = "en-US", + device_model: Optional[str] = None, + ) -> Dict[str, str]: + headers: Dict[str, str] = { + "Host": host or "android15.prod.cloud.netflix.com", + "Accept": "*/*", + "User-Agent": user_agent or MSL_ANDROID.DEFAULT_USER_AGENT, + "Accept-Language": language or "en-US", + "Accept-Encoding": "gzip, deflate, br", + "Content-Type": "application/json", + "Connection": "keep-alive", + "Content-Encoding": "msl_v1", + "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.client.context": MSL_ANDROID.DEFAULT_REQUEST_CONTEXT, + "x-netflix.request.client.languages": "en-US", + "x-netflix.request.client.timezoneid": "America/New_York", + "x-netflix.clienttype": "samurai", + "x-netflix.deviceformfactor": "PHONE", + "x-netflix.devicememorylevel": "HIGH", + "x-netflix.androidapi": "35", + "x-netflix.context.os-version": "35", + "x-netflix.context.form-factor": "phone", + "x-netflix.context.ui-flavor": "android", + "x-netflix.appver": "9.60.0", + "x-netflix.context.app-version": "9.60.0", + "x-netflix.esnprefix": "NFANDROID1-PRV-P-", + "x-netflix.zuul.brotli.allowed": "true", + "x-netflix.request.client.supportskidstop10": "true", + "x-netflix.request.client.supportsgames": "true", + "x-netflix.request.routing": '{"path":"\\/nq\\/android\\/playback\\/~1.0.0\\/router"}', + "x-netflix.context.locales": "en-US", + "x-netflix.context.android.installer-source": "com.android.vending", + } + 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 + headers["x-netflix.esn"] = esn + if expiry_timeout is not None: + headers["x-netflix.request.expiry.timeout"] = str(expiry_timeout) + if extra_headers: + headers.update(extra_headers) + return headers + + @staticmethod + def manifest_request_defaults() -> Tuple[str, Dict[str, str]]: + 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 + ) + + @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 diff --git a/modules/msl_ios.py b/modules/msl_ios.py new file mode 100644 index 0000000..cbbb581 --- /dev/null +++ b/modules/msl_ios.py @@ -0,0 +1,566 @@ +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 +from Cryptodome.Hash import HMAC, SHA256 +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)}>" + + +class MSLKeys(MSLObject): + def __init__( + self, + encryption: Optional[bytes] = None, + sign: Optional[bytes] = None, + rsa: Optional[RsaKey] = None, + mastertoken: Optional[dict] = None, + cdm_session: Any = None, + ): + self.encryption = encryption + self.sign = sign + self.rsa = rsa + self.mastertoken = mastertoken + self.cdm_session = cdm_session + + +class MSL_IOS: + DEFAULT_HANDSHAKE_ENDPOINT = "https://ios.prod.ftl.netflix.com/nq/iosplatform/pbo_license/~1.0.0/router" + DEFAULT_MANIFEST_ENDPOINT = "https://ios.prod.ftl.netflix.com/msl/playapi/ios/manifest" + DEFAULT_MANIFEST_PARAMS = { + "ab_ui_ver": "darwin", + "nrdapp_version": "18.26.0", + } + DEFAULT_USER_AGENT = "Netflix/5850 CFNetwork/3826.600.41 Darwin/24.6.0" + DEFAULT_REQUEST_CONTEXT = '{"appView":"login","appState":"foreground"}' + DEFAULT_NRDJS_VERSION = "v3.12.55" + DEFAULT_NETJS_VERSION = "3.0.5" + DEFAULT_PBO_VERSION = 2 + DEFAULT_PBO_COMMON = { + "sdk": "18.26.0", + "platform": "18.26.0", + "application": "Netflix iOS 18.26.0", + "uiversion": "18.26.0", + "uiPlatform": "ios", + "clientVersion": "18.26.0", + "apkVersion": "18.26.0", + } + DEFAULT_PBO_LANGUAGES = ["en-US", "en"] + DEFAULT_DEVICE_MODEL = "iPhone15%2C3" + + def __init__( + self, + session: requests.Session, + keys: MSLKeys, + message_id: int, + sender: str, + user_auth: Optional[dict] = None, + drm: str = "widevine", + ): + self.session = session + self.keys = keys + self.sender = sender + self.user_auth = user_auth + self.message_id = message_id + self.drm = drm + + @classmethod + def handshake( + cls, + msl_keys_path: str, + session: requests.Session, + sender: str, + cdm: Any, + cdm_device: Any, + new_msl: bool, + cookies: Optional[Dict[str, str]], + drm: str, + endpoint: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + ) -> MSLKeys: + if cookies: + session.cookies.update(cookies) + + cache_path = Path(msl_keys_path) + msl_keys = MSL_IOS.load_cache_data(cache_path) + if msl_keys is not None and not new_msl: + return msl_keys + + if drm != "widevine": + raise ValueError(f"Unsupported DRM mode: {drm}") + + if not cdm: + raise ValueError("Widevine CDM is required for this iOS MSL flow") + + message_id = random.randint(0, pow(2, 52)) + msl_keys = MSLKeys() + + 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), + ) + wv_request = base64.b64encode(challenge).decode("utf-8") + keyrequestdata = { + "scheme": "WIDEVINE", + "keydata": { + "keyrequest": wv_request, + }, + } + + data = jsonpickle.encode( + { + "entityauthdata": { + "scheme": "NONE", + "authdata": { + "identity": sender, + }, + }, + "headerdata": base64.standard_b64encode( + MSL_IOS.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="ios.prod.ftl.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"Key exchange failed: HTTP {res.status_code} {res.text[:500]}") + + parsed = cls.parse_concatenated_json(res.text) + if not parsed: + raise RuntimeError("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") + 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]}") + + 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"] + + cdm.parse_license(msl_keys.cdm_session, key_data["cdmkeyresponse"]) + keys = cdm.get_keys(msl_keys.cdm_session) + msl_keys.encryption = MSL_IOS.get_widevine_key( + kid=base64.standard_b64decode(key_data["encryptionkeyid"]), + keys=keys, + permissions=["AllowEncrypt", "AllowDecrypt"], + ) + msl_keys.sign = MSL_IOS.get_widevine_key( + kid=base64.standard_b64decode(key_data["hmackeyid"]), + keys=keys, + permissions=["AllowSign", "AllowSignatureVerify"], + ) + + msl_keys.mastertoken = key_response_data["mastertoken"] + MSL_IOS.cache_keys(msl_keys, cache_path) + return msl_keys + + @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, + host: Optional[str] = "ios.prod.ftl.netflix.com", + language: Optional[str] = "en-US,en", + device_model: Optional[str] = None, + ) -> Dict[str, str]: + headers: Dict[str, str] = { + "Host": host or "ios.prod.ftl.netflix.com", + "Accept": "*/*", + "User-Agent": user_agent or MSL_IOS.DEFAULT_USER_AGENT, + "Accept-Language": language or "en-US,en", + "Accept-Encoding": "deflate,gzip", + "Content-Type": "application/json", + "Connection": "keep-alive", + "Content-Encoding": "msl_v1", + "X-Gibbon-Cache-Control": "no-cache", + "X-AllowCompression": "true", + "X-Client-Request-Id": str(random.randint(10**17, 10**18 - 1)), + "X-DeviceModel": device_model or MSL_IOS.DEFAULT_DEVICE_MODEL, + "X-Netflix.Client.Request.Name": request_name, + "X-Netflix.Request.Attempt": "1", + "X-Netflix.Request.NonJson.Headers": "true", + "X-Netflix.Request.Client.Context": MSL_IOS.DEFAULT_REQUEST_CONTEXT, + "X-Netflix.request.expiry.timeout": str(expiry_timeout if expiry_timeout is not None else 12750), + "x-netflix.client.nrdjs.version": MSL_IOS.DEFAULT_NRDJS_VERSION, + "x-netflix.client.netjs.version": MSL_IOS.DEFAULT_NETJS_VERSION, + "x-netflix.client.last-interacted-days": "0", + } + 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 + headers["x-netflix.esn"] = esn + if extra_headers: + headers.update(extra_headers) + return headers + + @staticmethod + def manifest_request_defaults() -> Tuple[str, Dict[str, str]]: + return MSL_IOS.DEFAULT_MANIFEST_ENDPOINT, dict(MSL_IOS.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) + 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 + + @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 + ) + + @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 diff --git a/modules/msl_mgk.py b/modules/msl_mgk.py new file mode 100644 index 0000000..69e77d0 --- /dev/null +++ b/modules/msl_mgk.py @@ -0,0 +1,1008 @@ +from __future__ import annotations +import base64 +import gzip +import json +import os +import random +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 +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__}>" + + +class MSLKeys(MSLObject): + def __init__( + self, + encryption: Optional[bytes] = None, + sign: Optional[bytes] = None, + mastertoken: Optional[dict] = None, + wrapdata: Optional[bytes] = None, + derivation_key: Optional[bytes] = None, + ) -> None: + self.encryption = encryption + self.sign = sign + self.mastertoken = mastertoken + self.wrapdata = wrapdata + self.derivation_key = derivation_key + + +class Scheme(Enum): + def __str__(self) -> str: + return str(self.value) + + +class EntityAuthenticationSchemes(Scheme): + ModelGroup = "MGK" + + +class UserAuthenticationSchemes(Scheme): + EmailPassword = "EMAIL_PASSWORD" + NetflixIDCookies = "NETFLIXID" + UserIDToken = "USER_ID_TOKEN" + + +class EntityAuthentication(MSLObject): + def __init__(self, scheme: EntityAuthenticationSchemes, authdata: Dict[str, Any]) -> None: + self.scheme = str(scheme) + self.authdata = authdata + + @classmethod + def ModelGroup(cls, identity: str) -> "EntityAuthentication": + return cls(EntityAuthenticationSchemes.ModelGroup, {"identity": identity}) + + +class UserAuthentication(MSLObject): + 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": + return cls( + UserAuthenticationSchemes.EmailPassword, + {"email": email, "password": password}, + ) + + @classmethod + def UserIDToken( + cls, + token_data: str, + signature: str, + master_token: dict, + ) -> "UserAuthentication": + return cls( + UserAuthenticationSchemes.UserIDToken, + { + "useridtoken": { + "tokendata": token_data, + "signature": signature, + }, + "mastertoken": master_token, + }, + ) + + @classmethod + def NetflixIDCookies( + cls, + netflixid: Optional[str], + securenetflixid: Optional[str], + ) -> "UserAuthentication": + return cls( + UserAuthenticationSchemes.NetflixIDCookies, + { + "netflixid": netflixid, + "securenetflixid": securenetflixid, + }, + ) + + +class MSL_MGK: + DEFAULT_MANIFEST_ENDPOINT = "https://api-global.netflix.com/playapi/nrdjs/manifest/1" + DEFAULT_MANIFEST_PARAMS = { + "ab_ui_ver": "darwin", + "nrdapp_version": "2025.2.2.0", + } + DEFAULT_USER_AGENT = ( + "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 = { + "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" + ), + "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( + [ + 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 + + def __init__( + self, + session: requests.Session, + sender: str, + keys: MSLKeys, + message_id: int, + 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 + 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) + + @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: + 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") + + inner = HMAC.new(cls.WRAP_SALT, digestmod=SHA256) + inner.update(encryption_key_16 + hmac_key_32) + + outer = HMAC.new(inner.digest(), digestmod=SHA256) + outer.update(cls.WRAP_INFO) + + return outer.digest()[:16] + + @staticmethod + def int_to_unsigned_bytes(value: int) -> 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: + 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 + 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: + normalized_server_public_key = cls.correct_null_bytes(server_public_key_wire) + server_public_key_raw = ( + normalized_server_public_key[1:] + if normalized_server_public_key[:1] == b"\x00" + else normalized_server_public_key + ) + 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]: + if derivation_key is None: + raise ValueError("derivation key is required for AUTHENTICATED_DH") + + salt_key = SHA384.new(derivation_key).digest() + hmac_value = HMAC.new(salt_key, digestmod=SHA384) + hmac_value.update(shared_secret_bytes) + raw_key_material = hmac_value.digest() + + encryption_key = raw_key_material[:16] + hmac_key = raw_key_material[16:48] + wrapping_key = cls.derive_wrapping_key(encryption_key, hmac_key) + + return encryption_key, hmac_key, wrapping_key + + @staticmethod + def msl_encrypt_v1(key_id: str, encryption_key_16: bytes, plaintext_bytes: bytes) -> bytes: + iv = get_random_bytes(16) + ciphertext = AES.new(encryption_key_16, AES.MODE_CBC, iv).encrypt( + pad(plaintext_bytes, AES.block_size) + ) + envelope = { + "keyid": key_id, + "iv": base64.b64encode(iv).decode("ascii"), + "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: + 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) + + return unpad(padded_plaintext, AES.block_size) + + @staticmethod + def msl_sign_b64(hmac_key_32: bytes, data_bytes: bytes) -> str: + 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: + 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") + + @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"], + "encoderformats": ["JSON"], + }, + "timestamp": int(datetime.now(timezone.utc).timestamp()), + "sender": sender, + "nonreplayable": False, + } + + if userauthdata: + header_data["userauthdata"] = userauthdata + + if keyrequestdata: + header_data["keyrequestdata"] = [keyrequestdata] + + return json.dumps(header_data, separators=(",", ":")) + + @classmethod + def handshake( + cls, + session: requests.Session, + sender: str, + kpekph_path: Optional[Union[str, Path]] = None, + msl_keys_path: Optional[Union[str, Path]] = None, + cookies: Optional[Dict[str, str]] = None, + headers: Optional[Dict[str, str]] = None, + timeout: int = 30, + new_msl: bool = False, + ) -> "MSL": + endpoint = "https://www.netflix.com/msl/playapi/cadmium/licensedmanifest/1" + message_id = random.randint(0, pow(2, 52)) + + if cookies: + session.cookies.update(cookies) + + cache_path = Path(msl_keys_path) if msl_keys_path else None + cached_keys = None if new_msl else cls.load_cache_data(cache_path) + + if cached_keys is not None: + return cls( + session=session, + sender=sender, + keys=cached_keys, + message_id=message_id, + cookies=cookies, + ) + + if not sender: + raise RuntimeError("Missing sender or ESNID for MGK handshake") + + if kpekph_path: + 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." + ) + + entity_encryption_key, entity_hmac_key, entity_wrapping_key = cls.load_kpe_kph_file( + resolved_kpekph_path + ) + + msl_keys = MSLKeys() + cached_wrapdata = cached_keys.wrapdata if cached_keys else None + cached_derivation_key = cached_keys.derivation_key if cached_keys else None + mechanism = "WRAP" if cached_wrapdata and cached_derivation_key else "MGK" + derivation_key = cached_derivation_key or entity_wrapping_key + + 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)), + "parametersid": "1", + } + + if mechanism == "WRAP": + key_data["wrapdata"] = cls.b64_encode_bytes(cached_wrapdata) + + key_request_data = { + "scheme": "AUTHENTICATED_DH", + "keydata": key_data, + } + entity_auth_data = EntityAuthentication.ModelGroup(sender).__dict__ + + header_plaintext = cls.generate_msg_header( + message_id=message_id, + sender=sender, + is_handshake=True, + keyrequestdata=key_request_data, + compression="GZIP", + ).encode("utf-8") + header_ciphertext = cls.msl_encrypt_v1(sender, entity_encryption_key, header_plaintext) + + payload_plaintext = cls.stable_json( + { + "messageid": message_id, + "data": "", + "sequencenumber": 1, + "endofmsg": True, + } + ).encode("utf-8") + payload_ciphertext = cls.msl_encrypt_v1(sender, entity_encryption_key, payload_plaintext) + + request_body = cls.stable_json( + { + "entityauthdata": entity_auth_data, + "headerdata": cls.b64_encode_bytes(header_ciphertext), + "signature": cls.msl_sign_b64(entity_hmac_key, header_ciphertext), + } + ) + request_body += cls.stable_json( + { + "payload": cls.b64_encode_bytes(payload_ciphertext), + "signature": cls.msl_sign_b64(entity_hmac_key, payload_ciphertext), + } + ) + + response = session.post( + url=endpoint, + data=request_body, + headers=headers or {}, + timeout=timeout, + ) + + if response.status_code != 200: + raise RuntimeError( + f"Key exchange failed: HTTP {response.status_code} {response.text[:500]}" + ) + + 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") + 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]}" + ) + + 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( + base64.b64decode(response_key_data["publickey"]) + ) + response_wrapdata = response_key_data.get("wrapdata") + + if response_wrapdata: + msl_keys.wrapdata = base64.b64decode(response_wrapdata) + else: + msl_keys.wrapdata = cached_wrapdata + + shared_secret = cls.dh_compute_shared_secret_bytes( + dh_private_key, + server_public_key_wire, + ) + encryption_key, sign_key, next_derivation_key = cls.kdf_authenticated_dh( + derivation_key, + shared_secret, + ) + + msl_keys.encryption = encryption_key + msl_keys.sign = sign_key + msl_keys.derivation_key = next_derivation_key + msl_keys.mastertoken = key_response_data["mastertoken"] + + if cache_path: + cls.cache_keys(msl_keys, cache_path) + + return cls( + session=session, + sender=sender, + keys=msl_keys, + message_id=message_id, + cookies=cookies, + ) + + def send_message( + self, + endpoint: Optional[str] = None, + params: Optional[Dict[str, str]] = None, + application_data: Optional[Dict[str, Any]] = None, + userauthdata: Optional[dict] = None, + headers: Optional[Dict[str, str]] = None, + timeout: int = 30, + ) -> Tuple[Dict[str, Any], Any]: + message = self.create_message(application_data or {}, userauthdata) + response = self.session.post( + url=endpoint or self.DEFAULT_MANIFEST_ENDPOINT, + data=message, + params=params or {}, + headers=headers or {}, + cookies=self.cookies, + timeout=timeout, + ) + + 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}") + + 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") + ) + raise RuntimeError(f"MSL response contains an error: {decoded_error}") + + return header, payload_data + + def create_message( + self, + application_data: Dict[str, Any], + userauthdata: Optional[dict] = None, + ) -> str: + self.message_id += 1 + + header_data = self.encrypt( + self.generate_msg_header( + message_id=self.message_id, + sender=self.sender, + is_handshake=False, + userauthdata=userauthdata, + compression="GZIP", + ) + ) + message = json.dumps( + { + "headerdata": base64.b64encode(header_data.encode("utf-8")).decode("utf-8"), + "signature": self.sign(header_data).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_chunk = self.encrypt( + json.dumps( + { + "messageid": self.message_id, + "data": compressed_application_data, + "compressionalgo": "GZIP", + "sequencenumber": 1, + "endofmsg": True, + }, + separators=(",", ":"), + ) + ) + message += json.dumps( + { + "payload": base64.b64encode(payload_chunk.encode("utf-8")).decode("utf-8"), + "signature": self.sign(payload_chunk).decode("utf-8"), + }, + separators=(",", ":"), + ) + + return message + + def parse_message(self, response: Any) -> Tuple[Dict[str, Any], Any]: + if hasattr(response, "text"): + message = response.text or "" + raw_content = response.content + status_code = getattr(response, "status_code", None) + content_type = ( + response.headers.get("Content-Type", "") + if getattr(response, "headers", None) + else "" + ) + else: + message = str(response or "") + raw_content = message.encode("utf-8", errors="ignore") + status_code = None + content_type = "" + + parsed_message = self.parse_concatenated_json(message) + + 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}" + ) + + 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 + + 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: + payload_chunk_json = json.loads( + base64.b64decode(payload_chunk["payload"]).decode("utf-8") + ) + payload_decrypted = self.aes_cbc_decrypt( + self.keys.encryption, + base64.b64decode(payload_chunk_json["iv"]), + base64.b64decode(payload_chunk_json["ciphertext"]), + ) + payload_decrypted_json = json.loads(payload_decrypted.decode("utf-8")) + payload_data = base64.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") + + data = json.loads(raw_data) + + if "error" in data: + raise RuntimeError(data["error"]) + + if "result" not in data: + return data + + 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()) + + 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 = os.urandom(16) + 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"), + ) + + return json.dumps( + { + "ciphertext": base64.b64encode(ciphertext).decode("utf-8"), + "keyid": f"{self.sender}_{token_data['sequencenumber']}", + "sha256": "AA==", + "iv": base64.b64encode(iv).decode("utf-8"), + }, + separators=(",", ":"), + ) + + def sign(self, text: str) -> bytes: + 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)) + + @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) + + +__all__ = [ + "EntityAuthentication", + "EntityAuthenticationSchemes", + "MSL", + "MSLKeys", + "MSLObject", + "Scheme", + "UserAuthentication", + "UserAuthenticationSchemes", +] \ No newline at end of file diff --git a/modules/msl_tv.py b/modules/msl_tv.py new file mode 100644 index 0000000..a270b84 --- /dev/null +++ b/modules/msl_tv.py @@ -0,0 +1,599 @@ +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 +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)}>" + + +class MSLKeys(MSLObject): + def __init__( + self, + encryption: Optional[bytes] = None, + sign: Optional[bytes] = None, + rsa: Optional[RsaKey] = None, + mastertoken: Optional[dict] = None, + cdm_session: Any = None, + ): + self.encryption = encryption + self.sign = sign + 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 = { + "ab_ui_ver": "darwin", + "nrdapp_version": "2025.2.3.0", + } + DEFAULT_USER_AGENT = ( + "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 = { + "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", + "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" + + def __init__( + self, + session: requests.Session, + keys: MSLKeys, + message_id: int, + sender: str, + user_auth: Optional[dict] = None, + drm: str = "widevine", + ): + self.session = session + self.keys = keys + self.sender = sender + self.user_auth = user_auth + self.message_id = message_id + self.drm = drm + + @classmethod + def handshake( + cls, + msl_keys_path: str, + session: requests.Session, + sender: str, + cdm: Any, + cdm_device: Any, + new_msl: bool, + cookies: Optional[Dict[str, str]], + drm: str, + endpoint: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + ) -> MSLKeys: + if cookies: + session.cookies.update(cookies) + + cache_path = Path(msl_keys_path) + msl_keys = MSL_TV.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() + if not cdm and drm == "widevine": + msl_keys.rsa = RSA.generate(2048) + assert msl_keys.rsa is not None + keyrequestdata = { + "scheme": "ASYMMETRIC_WRAPPED", + "keydata": { + "keypairid": "rsaKeypairId", + "mechanism": "JWK_RSA", + "publickey": base64.b64encode( + msl_keys.rsa.publickey().export_key(format="DER") + ).decode("utf-8"), + }, + } + elif drm == "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)) + wv_request = base64.b64encode(challenge).decode("utf-8") + keyrequestdata = { + "scheme": "WIDEVINE", + "keydata": { + "keyrequest": wv_request, + }, + } + else: + raise ValueError(f"Unsupported DRM mode: {drm}") + + data = jsonpickle.encode( + { + "entityauthdata": { + "scheme": "NONE", + "authdata": { + "identity": sender, + }, + }, + "headerdata": base64.standard_b64encode( + MSL_TV.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="nrdp25.prod.ftl.netflix.com", + language="en-US,en-PH,en", + ) + 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]}") + + parsed = cls.parse_concatenated_json(res.text) + if not parsed: + raise RuntimeError("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") + 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]}") + + 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"] + + if cdm: + 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( + kid=base64.standard_b64decode(key_data["encryptionkeyid"]), + keys=keys, + permissions=["AllowEncrypt", "AllowDecrypt"], + ) + msl_keys.sign = MSL_TV.get_widevine_key( + kid=base64.standard_b64decode(key_data["hmackeyid"]), + keys=keys, + permissions=["AllowSign", "AllowSignatureVerify"], + ) + else: + assert msl_keys.rsa is not None + cipher_rsa = PKCS1_OAEP.new(msl_keys.rsa) + msl_keys.encryption = MSL_TV.base64key_decode( + json.loads( + cipher_rsa.decrypt(base64.standard_b64decode(key_data["encryptionkey"])).decode("utf-8") + )["k"] + ) + msl_keys.sign = MSL_TV.base64key_decode( + json.loads( + 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) + return msl_keys + + @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, + host: Optional[str] = "nrdp25.prod.ftl.netflix.com", + language: Optional[str] = "en-US,en-PH,en", + device_model: Optional[str] = None, + ) -> Dict[str, str]: + headers: Dict[str, str] = { + "Host": host or "nrdp25.prod.ftl.netflix.com", + "Language": language or "en-US,en-PH,en", + "User-Agent": user_agent or MSL_TV.DEFAULT_USER_AGENT, + "Accept": "*/*", + "Connection": "Keep-Alive", + "Accept-Encoding": "deflate,gzip", + "Content-Type": "application/json", + "X-Gibbon-Cache-Control": "no-cache", + "X-AllowCompression": "true", + "X-Client-Request-Id": str(random.randint(10**17, 10**18 - 1)), + "X-DeviceModel": device_model or MSL_TV.DEFAULT_DEVICE_MODEL, + "x-netflix.client.nrdjs.version": MSL_TV.DEFAULT_NRDJS_VERSION, + "X-Netflix.Client.Request.Name": request_name, + "X-Netflix.request.attempt": "1", + "X-Netflix.Request.NonJson.Headers": "true", + "X-Netflix.Request.Client.Context": MSL_TV.DEFAULT_REQUEST_CONTEXT, + "x-netflix.client.netjs.version": MSL_TV.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_TV.DEFAULT_MANIFEST_ENDPOINT, dict(MSL_TV.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-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) + + @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 + + @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 + ) + + @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.rsa: + 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: + 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)) + if original_rsa: + msl_keys.rsa = original_rsa \ No newline at end of file diff --git a/modules/msl_web.py b/modules/msl_web.py new file mode 100644 index 0000000..b61471e --- /dev/null +++ b/modules/msl_web.py @@ -0,0 +1,476 @@ +from __future__ import annotations +import base64 +import gzip +import json +import random +import zlib +import jsonpickle +import requests +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 +from Cryptodome.PublicKey import RSA +from Cryptodome.PublicKey.RSA import RsaKey +from Cryptodome.Random import get_random_bytes +from Cryptodome.Util import Padding + + +class MSLObject: + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {jsonpickle.encode(self, unpicklable=False)}>" + + +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 + 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 = ( + "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" + + def __init__( + self, + session: requests.Session, + keys: MSLKeys, + message_id: int, + sender: str, + user_auth: Optional[dict] = None, + ): + self.session = session + self.keys = keys + self.sender = sender + self.user_auth = user_auth + self.message_id = message_id + + @classmethod + def handshake( + cls, + msl_keys_path: str | Path, + session: requests.Session, + sender: str, + new_msl: bool = False, + cookies: Optional[Dict[str, str]] = None, + endpoint: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + ) -> MSLKeys: + 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 + + message_id = random.randint(0, 2**52) + keys = MSLKeys() + keys.rsa = RSA.generate(2048) + + keyrequestdata = { + "scheme": "ASYMMETRIC_WRAPPED", + "keydata": { + "keypairid": "rsaKeypairId", + "mechanism": "JWK_RSA", + "publickey": base64.b64encode( + keys.rsa.publickey().export_key(format="DER") + ).decode("utf-8"), + }, + } + + envelope = 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, + ) + envelope += json.dumps( + { + "payload": base64.standard_b64encode( + json.dumps( + { + "messageid": message_id, + "data": "", + "sequencenumber": 1, + "endofmsg": True, + } + ).encode("utf-8") + ).decode("utf-8"), + "signature": "", + }, + separators=(",", ":"), + ) + + response = session.post( + url=endpoint or cls.DEFAULT_HANDSHAKE_ENDPOINT, + data=envelope, + headers=headers or cls.build_request_headers(request_name="aleProvision"), + timeout=30, + ) + if response.status_code != 200: + raise RuntimeError(f"Key exchange failed: HTTP {response.status_code} {response.text[:500]}") + + parsed = cls.parse_concatenated_json(response.text) + if not parsed: + raise RuntimeError("Key exchange failed: empty MSL response") + + header = parsed[0] + if "errordata" in header: + 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]}") + + 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"] + ) + keys.sign = cls.base64key_decode( + 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 + + @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_WEB.DEFAULT_USER_AGENT, + "Accept": "*/*", + "Content-Type": "application/json", + "Content-Encoding": "msl_v1", + "Origin": "https://www.netflix.com", + "X-Netflix.Client.Request.Name": request_name, + "X-Netflix.request.attempt": "1", + "X-Netflix.Request.NonJson.Headers": "true", + "X-Netflix.Request.Client.Context": MSL_WEB.DEFAULT_REQUEST_CONTEXT, + "x-netflix.client.nrdjs.version": MSL_WEB.DEFAULT_NRDJS_VERSION, + "x-netflix.client.netjs.version": MSL_WEB.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 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": [], + "encoderformats": [], + }, + "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) + + def send_message( + self, + endpoint: str, + params: Dict[str, str], + application_data: Any, + userauthdata: Optional[dict] = None, + headers: Optional[Dict[str, str]] = None, + ) -> Tuple[Dict[str, Any], Any]: + message = self.create_message(application_data, userauthdata) + 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")) + 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")) + + @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.rsa: + 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: + 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") + if original_rsa: + msl_keys.rsa = original_rsa + + @staticmethod + def cookiejar_to_list(cookie_jar: CookieJar) -> List[Dict[str, Any]]: + cookies: List[Dict[str, Any]] = [] + for cookie in cookie_jar: + cookies.append( + { + "name": cookie.name, + "value": cookie.value, + "domain": cookie.domain, + "path": cookie.path, + "secure": bool(cookie.secure), + "expires": cookie.expires, + "discard": bool(cookie.discard), + "rest": dict(cookie._rest), + } + ) + return cookies + + @staticmethod + def cookiejar_to_ordered_dict(cookie_jar: CookieJar) -> Dict[str, str]: + 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]: + path = Path(output_path) + payload = MSL_WEB.cookiejar_to_ordered_dict(cookie_jar) + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return payload + + @staticmethod + def save_cookiejar(cookie_jar: CookieJar, output_path: str | Path) -> None: + path = Path(output_path) + 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: + if isinstance(source, (str, Path)): + path = Path(source) + if not path.is_file(): + return + payload = json.loads(path.read_text(encoding="utf-8")) + else: + payload = source + + if isinstance(payload, dict): + session.cookies.update(payload) + return + + for item in payload: + session.cookies.set( + name=item["name"], + value=item["value"], + domain=item.get("domain"), + path=item.get("path", "/"), + secure=item.get("secure", False), + expires=item.get("expires"), + rest=item.get("rest", {}), + ) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2fea42f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +certifi==2026.5.20 +charset-normalizer==3.4.7 +click==8.4.1 +colorama==0.4.6 +construct==2.8.8 +idna==3.17 +jsonpickle==4.1.2 +protobuf==6.33.6 +pycryptodome==3.23.0 +pycryptodomex==3.23.0 +pymp4==1.4.0 +pywidevine==1.9.0 +PyYAML==6.0.3 +requests==2.34.2 +Unidecode==1.4.0 +urllib3==2.7.0