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 0000000..1591cec Binary files /dev/null and b/modules/__pycache__/__init__.cpython-310.pyc differ diff --git a/modules/__pycache__/config.cpython-310.pyc b/modules/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000..d2ba368 Binary files /dev/null and b/modules/__pycache__/config.cpython-310.pyc differ diff --git a/modules/__pycache__/msl_android.cpython-310.pyc b/modules/__pycache__/msl_android.cpython-310.pyc new file mode 100644 index 0000000..3e1924b Binary files /dev/null and b/modules/__pycache__/msl_android.cpython-310.pyc differ diff --git a/modules/__pycache__/msl_ios.cpython-310.pyc b/modules/__pycache__/msl_ios.cpython-310.pyc new file mode 100644 index 0000000..6c2d322 Binary files /dev/null and b/modules/__pycache__/msl_ios.cpython-310.pyc differ diff --git a/modules/__pycache__/msl_mgk.cpython-310.pyc b/modules/__pycache__/msl_mgk.cpython-310.pyc new file mode 100644 index 0000000..460d6e0 Binary files /dev/null and b/modules/__pycache__/msl_mgk.cpython-310.pyc differ diff --git a/modules/__pycache__/msl_tv.cpython-310.pyc b/modules/__pycache__/msl_tv.cpython-310.pyc new file mode 100644 index 0000000..37db415 Binary files /dev/null and b/modules/__pycache__/msl_tv.cpython-310.pyc differ diff --git a/modules/__pycache__/msl_web.cpython-310.pyc b/modules/__pycache__/msl_web.cpython-310.pyc new file mode 100644 index 0000000..24ae084 Binary files /dev/null and b/modules/__pycache__/msl_web.cpython-310.pyc differ 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