diff --git a/main.py b/main.py index f4d3540..64acb04 100644 --- a/main.py +++ b/main.py @@ -1,3615 +1,152 @@ 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 +import argparse, glob as _glob, os, re, sys, time 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.msl_android import MSL_ANDROID -from modules.msl_ios import MSL_IOS -from modules.msl_tv import MSL_TV -from modules.msl_web import MSL_WEB -from modules.msl_mgk import MSL_MGK -from modules.helpers import ( - ensure_output_dir, restore_auth_cookies, get_nfvdid, get_flow_session_cookies, - save_session_cookies, build_cookie_header, apply_set_cookie_headers, - dedupe_important_cookies, collect_important_cookies, generate_hex_id, - generate_netflix_uuid, generate_request_id, generate_esn_random_suffix, - decrypt_msl_header, extract_clcs_session_id, extract_rendition_id, - parse_flow_data, parse_msl_payload, extract_useridtoken_from_payload, - build_msl_trace_event, extract_key_id_from_mastertoken, request_args_to_dict, -) +from typing import List from modules.config import setup_config from modules.logging import setup_logger +from modules.platforms.android_rsa import run_android_rsa +from modules.platforms.android import run_android +from modules.platforms.ios import run_ios +from modules.platforms.tv import run_tv +from modules.platforms.tv_otp import run_tv_otp +from modules.platforms.web import run_web +from modules.platforms.mgk import run_mgk log = setup_logger("MSL HANDSHAKE") -config = setup_config() -EMAIL = config["NETFLIX"]["EMAIL"] -PASSWORD = config["NETFLIX"]["PASSWORD"] -def setup_session(verify_tls: bool = True) -> requests.Session: - session = requests.Session() - session.verify = verify_tls - session.headers.update({ - "User-Agent": "Mozilla/5.0", - "Accept": "*/*", - }) - return session -# ====================================================================== -# ANDROID -# ====================================================================== +class _ColoredHelpFormatter(argparse.HelpFormatter): + """HelpFormatter with ANSI colors applied after layout so column widths are unaffected.""" -def run_android_rsa(new_msl: bool = False, no_verify: bool = False): - logger = setup_logger('ANDROID MSL RSA') - output_dir = ensure_output_dir("android") - msl_cache_path = output_dir / "msl_keys_cache_android_rsa.json" - auth_cookies_path = output_dir / "netflix_auth_cookies_rsa.json" - useridtoken_path = output_dir / "netflix_auth_useridtoken_rsa.json" - tokens_output_path = output_dir / "netflix_auth_tokens_rsa.json" + _RST = "\033[0m" + _BOLD = "\033[1m" + _CYAN = "\033[36m" + _GREEN = "\033[32m" + _YELLOW = "\033[33m" - # NFCDCH-02-* ESN is accepted by the Android FTL endpoint without a WVD - esn = f"NFCDCH-02-{''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(32))}" - user_agent = f"com.netflix.mediaclient/63988 (Linux; U; Android 15; en_US; SM-F711N; Build/AP3A.240905.015.A2; Cronet/143.0.7445.0)" - device_model = "SM-F711N" + @staticmethod + def _tty() -> bool: + return hasattr(sys.stdout, "isatty") and sys.stdout.isatty() - session = setup_session(verify_tls=True) + def _c(self, codes: str, text: str) -> str: + return f"{codes}{text}{self._RST}" if self._tty() else text - response = session.post( - "https://android15.appboot.netflix.com/appboot/NFANDROID1-PRV-P-", - params={"keyVersion": "1"}, - headers={ - "Host": "android15.appboot.netflix.com", - "X-Netflix.Request.Client.Context": '{"appView":"unknown","appState":"foreground"}', - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": user_agent, - "Accept-Encoding": "gzip, deflate, br", - }, - timeout=30, - ) - nfvdid = get_nfvdid(session, response) - logger.info("Initial nfvdid cookie obtained") + def _format_usage(self, usage, actions, groups, prefix): + if prefix is None: + prefix = self._c(f"{self._YELLOW}{self._BOLD}", "usage") + ": " + return super()._format_usage(usage, actions, groups, prefix) - msl_headers = MSL_ANDROID.build_request_headers( - request_name="getProxyEsn", - user_agent=user_agent, - referer=None, - esn=esn, - expiry_timeout=12750, - host="android15.prod.cloud.netflix.com", - language="en-US,en", - device_model=quote(device_model, safe=""), - extra_headers={ - "Accept-Encoding": "gzip, deflate, br", - "Content-Encoding": "msl_v1", - "x-netflix.zuul.brotli.allowed": "true", - "x-netflix.appver": "9.60.0", - "x-netflix.clienttype": "samurai", - "x-netflix.request.client.context": '{"appView":"unknown","appState":"foreground"}', - "x-netflix.esnprefix": "NFANDROID1-PRV-P-", - "x-netflix.request.uuid": f"{generate_hex_id(8)}-{generate_hex_id(4)}-{generate_hex_id(4)}-{generate_hex_id(4)}-{generate_hex_id(12)}", - "x-netflix.androidapi": "35", - "x-netflix.deviceformfactor": "PHONE", - "x-netflix.devicememorylevel": "HIGH", - "x-netflix.request.attempt": "1", - "x-netflix.request.id": generate_hex_id(32), - "Content-Type": "application/json", - "x-netflix.client.request.name": "getProxyEsn", - "x-netflix.request.routing": '{"path":"\\/nq\\/android\\/playback\\/~1.0.0\\/router"}', - "user-agent": user_agent, - }, - ) + def start_section(self, heading: str | None) -> None: + if heading: + heading = self._c(f"{self._CYAN}{self._BOLD}", heading) + super().start_section(heading) - logger.info("Performing RSA/ASYMMETRIC_WRAPPED MSL handshake (no WVD needed)") - msl_keys = MSL_ANDROID.rsa_handshake( - msl_keys_path=str(msl_cache_path), - session=session, - sender=esn, - new_msl=new_msl, - cookies={"nfvdid": nfvdid}, - endpoint="https://android.prod.ftl.netflix.com/nq/androidui/pbo_license/~1.0.0/router", - headers=msl_headers, - ) - - msl_client = MSL_ANDROID( - session=session, - keys=msl_keys, - message_id=random.randint(0, 2**52), - sender=esn, - drm="widevine", - ) - - logger.info("MSL RSA key exchange completed") - - # The NFCDCH-02-* ESN triggers the web CLCS auth flow (not samurai useridtoken). - # After the MSL handshake the HTTP session carries Netflix cookies, so we use - # the same CLCSScreenUpdate GraphQL path that run_web() uses. - logger.info("Fetching login page and extracting CLCS session context") - login_response = session.get("https://www.netflix.com/login", timeout=30) - login_html = login_response.text - - clcs_session_id = None - rendition_id = None - patterns = [ - r'clcsSessionId[\\"\'": ]+([0-9a-f\-]{36})', - r'(? str: + text = super()._format_action(action) + if not self._tty(): + return text + # Color flag/option text (e.g. "-h, --help" or "--platform {…}") + # Applied after layout so len() calculations are already done. + return re.sub( + r"^(\s+)(-\S.*?)(\s{2,}|$)", + lambda m: m.group(1) + self._c(self._GREEN, m.group(2)) + m.group(3), + text, + flags=re.MULTILINE, ) - 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: - header_data = decrypt_msl_header(confirm_login_header["headerdata"], msl_client.keys.encryption, msl_client.keys.sign) - 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) - - try: - auth_cookies = save_session_cookies(session, AUTH_COOKIES_PATH, log) - except Exception: - 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 -# ====================================================================== +class _ColoredArgumentParser(argparse.ArgumentParser): + """ArgumentParser with colored --help and colored error messages.""" -def run_ios(wvd_path: Path, - new_msl: bool = False, no_verify: bool = False): - log = setup_logger('IOS MSL') + def error(self, message: str) -> None: + log.error(message) + self.print_usage(sys.stderr) + sys.exit(2) - OUTPUT_DIR = ensure_output_dir("ios") - 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" +_WVD_LOOP_DELAY = 10 # seconds between WVD iterations to avoid throttling - 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-{generate_esn_random_suffix(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: - restore_auth_cookies(session, AUTH_COOKIES_PATH, log) - - 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 = generate_request_id() - - 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 = get_nfvdid(session, response) - - 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", - ) - - nfvdid, flow_session_id = get_flow_session_cookies(session) - - 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": generate_request_id(), - "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 = extract_clcs_session_id(login_html) - rendition_id = extract_rendition_id(login_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": generate_request_id(), - "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) - - 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: - header_data = decrypt_msl_header(encrypted_header_b64, msl_client.keys.encryption, msl_client.keys.sign) - - except Exception: - log.exception("Failed to process the login response") - sys.exit(1) - - if status == "SUCCESS": - log.info("LOGIN SUCCESSFUL") +def _run_wvd_loop(run_fn, wvd_paths: List[Path], **kwargs) -> None: + passed: List[str] = [] + failed: List[str] = [] + for wvd_path in wvd_paths: + if passed or failed: + log.info("Waiting %ds before next WVD to avoid throttling...", _WVD_LOOP_DELAY) + time.sleep(_WVD_LOOP_DELAY) + log.info("--- [%d/%d] WVD: %s ---", len(passed) + len(failed) + 1, len(wvd_paths), wvd_path.name) try: - save_session_cookies(session, AUTH_COOKIES_PATH, log) - except Exception: - 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): - log = setup_logger('ANDROID TV MSL') - - OUTPUT_DIR = ensure_output_dir("tv") - 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 = request_args_to_dict(REQUEST_ARGS) - - 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": generate_hex_id(32, uppercase=True), - "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', - "X-Netflix.Request.NonJson.Headers": "true", - "x-netflix.client.netjs.version": 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": generate_hex_id(32, uppercase=True), - "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', - "X-Gibbon-Cache-Control": "no-cache", - "X-Netflix.request.expiry.timeout": "20000", - "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, parsed_payload, text_payload = parse_msl_payload(payload) - - key_id = extract_key_id_from_mastertoken(msl.keys.mastertoken) if msl.keys.mastertoken else "" - - event = build_msl_trace_event(msl.message_id, key_id, payload_type, parsed_payload, text_payload) - MSL_TRACE.append(event) - - useridtoken = extract_useridtoken_from_payload(parsed_payload, payload) - - 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": generate_hex_id(32, uppercase=True), - "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', - "X-Gibbon-Cache-Control": "no-cache", - "x-netflix.request.expiry.timeout": "20000", - "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 = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) - 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, - ) - - apply_set_cookie_headers(session, response, cookie_values) - - 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"] = generate_hex_id(32, uppercase=True) - session.headers["X-Netflix.request.toplevel.uuid"] = trace_uuid - session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid - session.headers["X-Netflix.context.operation-name"] = "clcsLegacyMoneyballSubmit" - - 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 = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) - 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, - ) - - apply_set_cookie_headers(session, response, cookie_values) - - response.raise_for_status() - submit_data = response.json() - mfa_nonce = session.cookies.get("netflix-mfa-nonce") - log.info("netflix-mfa-nonce: %s", (mfa_nonce[:80] + "...") if mfa_nonce else "missing") - - flow_update = parse_flow_data(submit_data) - - flow_session_id = flow_update.get("flowSessionId", flow_session_id) - clcs_session_id = flow_update.get("clcsSessionId", clcs_session_id) - rendition_id = flow_update.get("renditionId", rendition_id) - - # Step 5b: lrudSignInAction on webSignIn - trace_uuid = str(uuid.uuid4()) - session.headers["X-Netflix.request.id"] = generate_hex_id(32, uppercase=True) - session.headers["X-Netflix.request.toplevel.uuid"] = trace_uuid - session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid - session.headers["X-Netflix.context.operation-name"] = "clcsScreenUpdate" - - 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 = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) - 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 = parse_flow_data(step_web_signin) - - flow_session_id = flow_update.get("flowSessionId", flow_session_id) - clcs_session_id = flow_update.get("clcsSessionId", clcs_session_id) - rendition_id = flow_update.get("renditionId", rendition_id) - - # Step 5c: submitUserIdAction on enterMemberCredentials - trace_uuid = str(uuid.uuid4()) - session.headers["X-Netflix.request.id"] = generate_hex_id(32, uppercase=True) - session.headers["X-Netflix.request.toplevel.uuid"] = trace_uuid - session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid - session.headers["X-Netflix.context.operation-name"] = "clcsScreenUpdate" - - 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 = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) - 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 = parse_flow_data(step_user) - - flow_session_id = flow_update.get("flowSessionId", flow_session_id) - clcs_session_id = flow_update.get("clcsSessionId", clcs_session_id) - rendition_id = flow_update.get("renditionId", rendition_id) - - # Step 5d: usePasswordAction on loginLinkOption - trace_uuid = str(uuid.uuid4()) - session.headers["X-Netflix.request.id"] = generate_hex_id(32, uppercase=True) - session.headers["X-Netflix.request.toplevel.uuid"] = trace_uuid - session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid - session.headers["X-Netflix.context.operation-name"] = "clcsScreenUpdate" - - 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 = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) - 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 = parse_flow_data(step_password_path) - - flow_session_id = flow_update.get("flowSessionId", flow_session_id) - clcs_session_id = flow_update.get("clcsSessionId", clcs_session_id) - 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"] = generate_hex_id(32, uppercase=True) - session.headers["X-Netflix.request.toplevel.uuid"] = trace_uuid - session.headers["X-Netflix.tracing.cl.userActionId"] = trace_uuid - session.headers["X-Netflix.context.operation-name"] = "clcsScreenUpdate" - - 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 = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) - 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, - ) - - apply_set_cookie_headers(session, response, cookie_values) - - response.raise_for_status() - login_data = response.json() - LOGIN_RESPONSE_PATH.write_text(json.dumps(login_data, indent=2, ensure_ascii=False), encoding="utf-8") - - flow_result = parse_flow_data(login_data) - - 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": generate_hex_id(32, uppercase=True), - "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"browseTitles","appstate":"foreground","reason":"unknown"}', - "X-Gibbon-Cache-Control": "no-cache", - "x-netflix.request.expiry.timeout": "20000", - "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 = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) - 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, - ) - - apply_set_cookie_headers(session, response, cookie_values) - - 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, parsed_payload, text_payload = parse_msl_payload(payload) - - event = build_msl_trace_event(msl.message_id, "", payload_type, parsed_payload, text_payload) - MSL_TRACE.append(event) - - if isinstance(header, dict) and "headerdata" in header: - try: - header_data = decrypt_msl_header(header["headerdata"], msl.keys.encryption, msl.keys.sign) - tokens = header_data.get("useridtoken") - if tokens: - USER_ID_TOKEN_PATH.write_text(json.dumps(tokens, indent=2), encoding="utf-8") - log.info("useridtoken refreshed from %s", route) - except Exception: - pass + run_fn(wvd_path=wvd_path, **kwargs) + passed.append(wvd_path.name) + except SystemExit: + failed.append(wvd_path.name) + log.warning("WVD failed, continuing to next...") except Exception as exc: - log.warning("Post-login MSL refresh failed: %s", exc) + failed.append(wvd_path.name) + log.warning("WVD raised %s: %s — continuing to next...", type(exc).__name__, exc) - log.info("Save filtered cookies") - dedupe_important_cookies(session, IMPORTANT_COOKIE_NAMES) + log.info("=== Results: %d/%d passed ===", len(passed), len(wvd_paths)) + for name in passed: + log.info(" PASS: %s", name) + for name in failed: + log.warning(" FAIL: %s", name) - 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 = setup_logger('ANDROID TV MSL') - - OUTPUT_DIR = ensure_output_dir() - MSL_CACHE_PATH = OUTPUT_DIR / "msl_keys_cache.json" - USER_ID_TOKEN_PATH = OUTPUT_DIR / "useridtoken.json" - NETFLIX_COOKIES_PATH = OUTPUT_DIR / "netflix_cookies.json" - - 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-{generate_esn_random_suffix(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": generate_hex_id(32, uppercase=True), - "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', - "X-Netflix.Request.NonJson.Headers": "true", - "x-netflix.client.netjs.version": "3.0.5", - "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": generate_hex_id(32, uppercase=True), - "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', - "X-Gibbon-Cache-Control": "no-cache", - "X-Netflix.request.expiry.timeout": "20000", - "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, parsed_payload, text_payload = parse_msl_payload(payload) - - key_id = extract_key_id_from_mastertoken(msl.keys.mastertoken) if msl.keys.mastertoken else "" - - event = build_msl_trace_event( - msl.message_id, key_id, payload_type, parsed_payload, text_payload, - extra_fields={"_iv": None, "_ciphertextLen": None, "_plaintextLen": len(text_payload.encode("utf-8")) if isinstance(text_payload, str) else None}, - ) - - useridtoken = extract_useridtoken_from_payload(parsed_payload, payload) - - 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": generate_hex_id(32, uppercase=True), - "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', - "X-Gibbon-Cache-Control": "no-cache", - "X-Netflix.request.expiry.timeout": "20000", - "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, parsed_payload, text_payload = parse_msl_payload(payload) - - key_id = extract_key_id_from_mastertoken(msl.keys.mastertoken) if msl.keys.mastertoken else "" - - event = build_msl_trace_event( - msl.message_id, key_id, payload_type, parsed_payload, text_payload, - extra_fields={"_iv": None, "_ciphertextLen": None, "_plaintextLen": len(text_payload.encode("utf-8")) if isinstance(text_payload, str) else None}, - ) - - useridtoken = extract_useridtoken_from_payload(parsed_payload, payload) - - 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": generate_hex_id(32, uppercase=True), - "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', - "X-Gibbon-Cache-Control": "no-cache", - "x-netflix.request.expiry.timeout": "20000", - "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 = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) - headers = dict(session.headers) - if cookie_header: - headers["Cookie"] = cookie_header - - response = session.post(graphql_url, json=body, headers=headers, timeout=30) - - apply_set_cookie_headers(session, response, cookie_values) - - dedupe_important_cookies(session, IMPORTANT_COOKIE_NAMES) - - response.raise_for_status() - init_data = response.json() - if "errors" in init_data: - raise RuntimeError(json.dumps(init_data["errors"], indent=2)) - - flow = parse_flow_data(init_data) - - 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": generate_hex_id(32, uppercase=True), - "X-Netflix.Request.Client.Context": '{"canvas":"OTHER","feature":"OTHER","appView":"appLoading","appstate":"foreground","reason":"unknown"}', - "X-Gibbon-Cache-Control": "no-cache", - "x-netflix.request.expiry.timeout": "20000", - "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 = request_args_to_dict(REQUEST_ARGS) - - 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 = build_cookie_header(session, IMPORTANT_COOKIE_NAMES) - headers = dict(session.headers) - if cookie_header: - headers["Cookie"] = cookie_header - - response = session.post(graphql_url, json=body, headers=headers, timeout=30) - - apply_set_cookie_headers(session, response, cookie_values) - - dedupe_important_cookies(session, IMPORTANT_COOKIE_NAMES) - - response.raise_for_status() - submit_data = response.json() - - flow2 = parse_flow_data(submit_data) - - 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'(? logging.Logger: - """Create and return a logger with optional colored output. + """Return a named logger, configuring the root handler on first call. - If coloredlogs is installed, the logger will use colored output. - Otherwise, falls back to standard logging with the specified format. + All loggers propagate to a single root handler so that module-level + loggers (``_log = logging.getLogger(__name__)``) automatically inherit + the same coloredlogs formatting without extra setup. """ + global _ROOT_CONFIGURED + + if not _ROOT_CONFIGURED: + root_level = logging.DEBUG if os.getenv("MSL_DEBUG") else logging.INFO + _fmt = fmt or DEFAULT_FMT + + if _COLOREDLOGS: + coloredlogs.install( + level=root_level, + fmt=_fmt, + level_styles=LEVEL_STYLES, + field_styles=FIELD_STYLES, + ) + else: + logging.basicConfig(level=root_level, format=_fmt) + + _ROOT_CONFIGURED = True + logger = logging.getLogger(name) - if logger.handlers: - return logger - - if fmt is None: - fmt = "%(name)s - %(levelname)s - %(message)s" - - if COLOREDLOGS_AVAILABLE: - coloredlogs.install( - level=level, - fmt=fmt, - logger=logger, - reconfigure=True, - ) - else: - handler = logging.StreamHandler() - handler.setFormatter(logging.Formatter(fmt)) - logger.addHandler(handler) - logger.setLevel(level) - - return logger \ No newline at end of file + logger.setLevel(level) + return logger diff --git a/modules/msl/__init__.py b/modules/msl/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/modules/msl/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/modules/msl_android.py b/modules/msl/android.py similarity index 94% rename from modules/msl_android.py rename to modules/msl/android.py index 3d2dc8f..b7745cc 100644 --- a/modules/msl_android.py +++ b/modules/msl/android.py @@ -2,8 +2,11 @@ from __future__ import annotations import base64 import json +import logging import random from pathlib import Path + +_log = logging.getLogger(__name__) from typing import Any, Dict, List, Optional, Tuple import jsonpickle @@ -14,7 +17,7 @@ from Cryptodome.PublicKey import RSA from Cryptodome.PublicKey.RSA import RsaKey from pywidevine import Cdm as WidevineCdm, Device as WidevineDevice, PSSH -from modules.msl_base import MSLBase, MSLKeys as _BaseMSLKeys, get_widevine_key +from modules.msl.base import MSLBase, MSLKeys as _BaseMSLKeys, get_widevine_key # --------------------------------------------------------------------------- @@ -86,8 +89,9 @@ class MSL_ANDROID(MSLBase): sender: str, user_auth: Optional[dict] = None, drm: str = "widevine", + proxy: Optional[Dict[str, str]] = None, ) -> None: - super().__init__(session=session, keys=keys, message_id=message_id, sender=sender) + super().__init__(session=session, keys=keys, message_id=message_id, sender=sender, proxy=proxy) self.user_auth = user_auth self.drm = drm @@ -108,13 +112,16 @@ class MSL_ANDROID(MSLBase): headers: Optional[Dict[str, str]] = None, ) -> MSLKeys: """Perform a Widevine key exchange and return negotiated keys.""" + _log.info("Android Widevine handshake: sender=%s", sender) if cookies: session.cookies.update(cookies) cache_path = Path(msl_keys_path) msl_keys = cls.load_cache_data(cache_path) if msl_keys is not None and not new_msl: + _log.info("Reusing cached MSL keys") return msl_keys + _log.info("Performing fresh Widevine key exchange") if drm != "widevine": raise ValueError(f"Unsupported DRM mode: {drm}") @@ -135,6 +142,7 @@ class MSL_ANDROID(MSLBase): cdm_session, PSSH.new(system_id=PSSH.SystemId.Widevine), ) + _log.debug("Widevine challenge created (%d bytes)", len(challenge)) wv_request = base64.b64encode(challenge).decode("utf-8") keyrequestdata = { "scheme": "WIDEVINE", @@ -186,9 +194,11 @@ class MSL_ANDROID(MSLBase): host="android15.prod.cloud.netflix.com", language="en-US,en", ) + _log.debug("Widevine handshake request → %s", handshake_endpoint) res = session.post( url=handshake_endpoint, data=data, headers=handshake_headers, timeout=30 ) + _log.debug("Widevine handshake response ← HTTP %d", res.status_code) if res.status_code != 200: raise RuntimeError( @@ -235,6 +245,7 @@ class MSL_ANDROID(MSLBase): msl_keys.mastertoken = key_response_data["mastertoken"] cls.cache_keys(msl_keys, cache_path) + _log.info("Widevine key exchange complete") return msl_keys # -- RSA / ASYMMETRIC_WRAPPED handshake (no Widevine required) ----------- @@ -259,16 +270,20 @@ class MSL_ANDROID(MSLBase): The ESN must use the ``NFCDCH-02-`` prefix (web-style) so that the Android FTL endpoint accepts the ``NONE`` entity auth scheme. """ + _log.info("Android RSA handshake: sender=%s", sender) 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: + _log.info("Reusing cached RSA MSL keys") return cached + _log.info("Performing fresh RSA key exchange") # ---- Generate ephemeral RSA-2048 keypair ---------------------------- rsa_key = RSA.generate(2048) + _log.debug("Generated RSA-2048 ephemeral keypair") pub_der_b64 = base64.b64encode( rsa_key.publickey().export_key("DER") ).decode("ascii") @@ -326,12 +341,14 @@ class MSL_ANDROID(MSLBase): host="android15.prod.cloud.netflix.com", language="en-US,en", ) + _log.debug("RSA handshake request → %s", handshake_endpoint) res = session.post( url=handshake_endpoint, data=data, headers=handshake_headers, timeout=30, ) + _log.debug("RSA handshake response ← HTTP %d", res.status_code) if res.status_code != 200: raise RuntimeError( @@ -374,6 +391,7 @@ class MSL_ANDROID(MSLBase): # Don't persist the RSA key object (not picklable); clear it before caching msl_keys.rsa = None cls.cache_keys(msl_keys, cache_path) + _log.info("RSA key exchange complete") return msl_keys @staticmethod diff --git a/modules/msl_base.py b/modules/msl/base.py similarity index 96% rename from modules/msl_base.py rename to modules/msl/base.py index fd9d71d..20ec0c5 100644 --- a/modules/msl_base.py +++ b/modules/msl/base.py @@ -4,10 +4,13 @@ from __future__ import annotations import base64 import gzip import json +import logging import random import re import zlib +_log = logging.getLogger(__name__) + import jsonpickle import requests from io import BytesIO @@ -99,12 +102,14 @@ class MSLBase: keys: MSLKeys, message_id: int, sender: str, + proxy: Optional[Dict[str, str]] = None, **_kwargs: Any, ) -> None: self.session = session self.keys = keys self.sender = sender self.message_id = message_id + self.proxy = proxy # ----------------------------------------------------------------------- # JSON helpers @@ -364,6 +369,7 @@ class MSLBase: timeout: int = 30, ) -> Tuple[Dict[str, Any], Any]: message = self.create_message(application_data, userauthdata) + _log.debug("MSL → %s", endpoint) request_kwargs: Dict[str, Any] = { "url": endpoint, "data": message, @@ -371,12 +377,15 @@ class MSLBase: "headers": headers, "timeout": timeout, } - if proxy: - request_kwargs["proxies"] = proxy + effective_proxy = proxy or self.proxy + if effective_proxy: + request_kwargs["proxies"] = effective_proxy res = self.session.post(**request_kwargs) + _log.debug("MSL ← HTTP %d (%d bytes)", res.status_code, len(res.content)) if res.status_code != 200: + _log.warning("MSL request failed: HTTP %d — %s", res.status_code, res.text[:200]) raise RuntimeError( f"MSL request failed with HTTP {res.status_code}: {res.text[:500]}" ) @@ -480,6 +489,7 @@ class MSLBase: """Load cached MSL keys from disk. Returns ``None`` if the cache is missing, corrupt, or the token is about to expire (< 10 h remaining).""" if not msl_keys_path or not msl_keys_path.is_file(): + _log.debug("MSL cache miss: %s", msl_keys_path) return None msl_keys = jsonpickle.decode(msl_keys_path.read_text(encoding="utf-8")) @@ -492,11 +502,14 @@ class MSLBase: ) remaining_hours = (renewal_window - datetime.now(timezone.utc)).total_seconds() / 3600 if remaining_hours < 10: + _log.debug("MSL cache expired (%.1fh remaining): %s", remaining_hours, msl_keys_path) return None + _log.debug("MSL cache hit: %s", msl_keys_path) return msl_keys @staticmethod def cache_keys(msl_keys: MSLKeys, msl_keys_path: Path) -> None: """Persist *msl_keys* to *msl_keys_path*.""" + _log.debug("Caching MSL keys → %s", msl_keys_path) msl_keys_path.parent.mkdir(parents=True, exist_ok=True) msl_keys_path.write_text(jsonpickle.encode(msl_keys, indent=4), encoding="utf-8") diff --git a/modules/msl_ios.py b/modules/msl/ios.py similarity index 97% rename from modules/msl_ios.py rename to modules/msl/ios.py index cbbb581..98bff1e 100644 --- a/modules/msl_ios.py +++ b/modules/msl/ios.py @@ -1,9 +1,12 @@ import base64 import gzip import json +import logging import random import sys import zlib + +_log = logging.getLogger(__name__) import jsonpickle import requests from io import BytesIO @@ -71,6 +74,7 @@ class MSL_IOS: sender: str, user_auth: Optional[dict] = None, drm: str = "widevine", + proxy: Optional[Dict[str, str]] = None, ): self.session = session self.keys = keys @@ -78,6 +82,7 @@ class MSL_IOS: self.user_auth = user_auth self.message_id = message_id self.drm = drm + self.proxy = proxy @classmethod def handshake( @@ -93,13 +98,16 @@ class MSL_IOS: endpoint: Optional[str] = None, headers: Optional[Dict[str, str]] = None, ) -> MSLKeys: + _log.info("iOS Widevine handshake: sender=%s", sender) 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: + _log.info("Reusing cached MSL keys") return msl_keys + _log.info("Performing fresh Widevine key exchange") if drm != "widevine": raise ValueError(f"Unsupported DRM mode: {drm}") @@ -171,7 +179,9 @@ class MSL_IOS: host="ios.prod.ftl.netflix.com", language="en-US,en", ) + _log.debug("Widevine handshake request → %s", handshake_endpoint) res = session.post(url=handshake_endpoint, data=data, headers=handshake_headers, timeout=30) + _log.debug("Widevine handshake response ← HTTP %d", res.status_code) if res.status_code != 200: raise RuntimeError(f"Key exchange failed: HTTP {res.status_code} {res.text[:500]}") @@ -211,6 +221,7 @@ class MSL_IOS: msl_keys.mastertoken = key_response_data["mastertoken"] MSL_IOS.cache_keys(msl_keys, cache_path) + _log.info("Widevine key exchange complete") return msl_keys @staticmethod @@ -325,8 +336,9 @@ class MSL_IOS: "headers": headers, "timeout": 30, } - if proxy: - request_kwargs["proxies"] = proxy + effective_proxy = proxy or self.proxy + if effective_proxy: + request_kwargs["proxies"] = effective_proxy res = self.session.post(**request_kwargs) header, payload_data = self.parse_message(res.text) if "errordata" in header: diff --git a/modules/msl_mgk.py b/modules/msl/mgk.py similarity index 60% rename from modules/msl_mgk.py rename to modules/msl/mgk.py index d1ca580..8e8a011 100644 --- a/modules/msl_mgk.py +++ b/modules/msl/mgk.py @@ -2,10 +2,12 @@ from __future__ import annotations import base64 import json +import logging import os import random import re -import zlib + +_log = logging.getLogger(__name__) from datetime import datetime, timezone from enum import Enum from pathlib import Path @@ -18,81 +20,49 @@ from Cryptodome.Hash import HMAC, SHA256, SHA384 from Cryptodome.Random import get_random_bytes from Cryptodome.Util.Padding import pad, unpad -from .msl_base import MSLBase, MSLKeys as _BaseMSLKeys, MSLObject +from .base import MSLBase, MSLKeys as _BaseMSLKeys # --------------------------------------------------------------------------- -# Platform-specific key container -# --------------------------------------------------------------------------- - -class MSLKeys(_BaseMSLKeys): - """MGK MSL keys – extends the base with wrapping data and derivation key.""" - - def __init__( - self, - encryption: Optional[bytes] = None, - sign: Optional[bytes] = None, - mastertoken: Optional[dict] = None, - wrapdata: Optional[bytes] = None, - derivation_key: Optional[bytes] = None, - ) -> None: - super().__init__(encryption=encryption, sign=sign, mastertoken=mastertoken) - self.wrapdata = wrapdata - self.derivation_key = derivation_key - - -# --------------------------------------------------------------------------- -# Authentication schemes +# MGK-specific Enums # --------------------------------------------------------------------------- class Scheme(Enum): - """Base enum for MSL authentication schemes.""" - def __str__(self) -> str: return str(self.value) class EntityAuthenticationSchemes(Scheme): - """Supported entity authentication schemes for MGK.""" - ModelGroup = "MGK" class UserAuthenticationSchemes(Scheme): - """Supported user authentication schemes for MGK.""" - EmailPassword = "EMAIL_PASSWORD" NetflixIDCookies = "NETFLIXID" UserIDToken = "USER_ID_TOKEN" -class EntityAuthentication(MSLObject): - """Entity authentication data for MGK handshake.""" +# --------------------------------------------------------------------------- +# MGK Entity / User Authentication data classes +# --------------------------------------------------------------------------- - def __init__( - self, scheme: EntityAuthenticationSchemes, authdata: Dict[str, Any] - ) -> None: +class EntityAuthentication: + def __init__(self, scheme: EntityAuthenticationSchemes, authdata: Dict[str, Any]) -> None: self.scheme = str(scheme) self.authdata = authdata @classmethod def ModelGroup(cls, identity: str) -> "EntityAuthentication": - """Create an MGK entity authentication with the given identity.""" return cls(EntityAuthenticationSchemes.ModelGroup, {"identity": identity}) -class UserAuthentication(MSLObject): - """User authentication data for MGK requests.""" - - def __init__( - self, scheme: UserAuthenticationSchemes, authdata: Dict[str, Any] - ) -> None: +class UserAuthentication: + def __init__(self, scheme: UserAuthenticationSchemes, authdata: Dict[str, Any]) -> None: self.scheme = str(scheme) self.authdata = authdata @classmethod def EmailPassword(cls, email: str, password: str) -> "UserAuthentication": - """Create an email/password user authentication.""" return cls( UserAuthenticationSchemes.EmailPassword, {"email": email, "password": password}, @@ -105,7 +75,6 @@ class UserAuthentication(MSLObject): signature: str, master_token: dict, ) -> "UserAuthentication": - """Create a user-ID-token authentication.""" return cls( UserAuthenticationSchemes.UserIDToken, { @@ -123,7 +92,6 @@ class UserAuthentication(MSLObject): netflixid: Optional[str], securenetflixid: Optional[str], ) -> "UserAuthentication": - """Create a Netflix-ID-cookie-based authentication.""" return cls( UserAuthenticationSchemes.NetflixIDCookies, { @@ -134,19 +102,57 @@ class UserAuthentication(MSLObject): # --------------------------------------------------------------------------- -# MSL_MGK +# Platform-specific key container +# --------------------------------------------------------------------------- + +class MSLKeys(_BaseMSLKeys): + """MGK MSL keys -- extends the base with wrapdata, derivation key, and DH fields.""" + + def __init__( + self, + encryption: Optional[bytes] = None, + sign: Optional[bytes] = None, + mastertoken: Optional[dict] = None, + wrapdata: Optional[bytes] = None, + derivation_key: Optional[bytes] = None, + key_scheme: Optional[str] = None, + key_mechanism: Optional[str] = None, + server_public_key_b64: Optional[str] = None, + userauthdata: Optional[dict] = None, + useridtoken: Optional[dict] = None, + ) -> None: + super().__init__(encryption=encryption, sign=sign, mastertoken=mastertoken) + self.wrapdata = wrapdata + self.derivation_key = derivation_key + self.key_scheme = key_scheme + self.key_mechanism = key_mechanism + self.server_public_key_b64 = server_public_key_b64 + self.userauthdata = userauthdata + self.useridtoken = useridtoken + + +# --------------------------------------------------------------------------- +# MSL_MGK -- Netflix MSL client for MGK (Model Group Key) devices # --------------------------------------------------------------------------- class MSL_MGK(MSLBase): - """Netflix MSL client using AUTHENTICATED_DH (Model Group Key) exchange. + """Netflix MSL client for MGK (Model Group Key) authenticated devices. - This platform is fundamentally different from the Widevine/RSA variants: - it uses Diffie-Hellman key agreement with entity authentication, has its - own wrapping key derivation, and structures MSL messages with a single - payload chunk instead of two. + Unlike the other platform clients (Android / iOS / TV / Web) which use + Widevine or RSA key exchange, the MGK client uses an AUTHENTICATED_DH + scheme with entity authentication. The KpeKph (entity encryption + HMAC + keys) are provided by the caller rather than derived from a CDM. + + This class extends :class:`MSLBase` and inherits all shared protocol + logic (encrypt, sign, create_message, send_message, parse_message, etc.) + while adding MGK-specific crypto (DH key exchange, KDF, wrap key + derivation) and overriding ``handshake()`` and ``build_request_headers()``. """ # -- Platform constants -------------------------------------------------- + DEFAULT_HANDSHAKE_ENDPOINT: str = ( + "https://www.netflix.com/msl/playapi/cadmium/licensedmanifest/1" + ) DEFAULT_MANIFEST_ENDPOINT: str = ( "https://api-global.netflix.com/playapi/nrdjs/manifest/1" ) @@ -162,25 +168,22 @@ class MSL_MGK(MSLBase): DEFAULT_REQUEST_CONTEXT: str = '{"appstate":"foreground","reason":"unknown"}' DEFAULT_NRDJS_VERSION: str = "v3.11.512" DEFAULT_NETJS_VERSION: str = "3.0.5" + DEFAULT_PBO_VERSION: int = 2 DEFAULT_PBO_COMMON: Dict[str, str] = { "sdk": "2025.2.2.0", "platform": "2025.2.2.0", "application": ( "12.1.6-23045 R 2025.2 android-30-JPLAYER2 " - "ninja_6==NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/" - "7825230_4040.2147:user/release-keys" - ), - "uiversion": ( - "UI-release-20260303_43809-gibbon-r100-darwinql-69067=" - "5,78214=2,78929=8,80045=1,80048=2" + "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: List[str] = ["en-CA", "en-US", "en"] - # -- DH / wrapping constants --------------------------------------------- + # -- MGK DH constants ---------------------------------------------------- WRAP_SALT: bytes = bytes.fromhex("027617984f6227539a630b897c017d69") WRAP_INFO: bytes = bytes.fromhex("809f82a7addf548d3ea9dd067ff9bb91") DH_PRIME: bytes = bytes( @@ -203,7 +206,7 @@ class MSL_MGK(MSLBase): 0xA8, 0xE5, 0x20, 0xE7, 0x96, 0xDE, 0x27, 0xDF, ] ) - DH_P: int = int.from_bytes(DH_PRIME, "big") + DH_P: int = 0 # computed at class body end DH_G: int = 5 # -- Constructor --------------------------------------------------------- @@ -211,25 +214,24 @@ class MSL_MGK(MSLBase): def __init__( self, session: requests.Session, - sender: str, keys: MSLKeys, message_id: int, + sender: str, user_auth: Optional[dict] = None, cookies: Optional[Dict[str, str]] = None, + proxy: Optional[Dict[str, str]] = None, ) -> None: - super().__init__(session=session, keys=keys, message_id=message_id, sender=sender) + super().__init__(session=session, keys=keys, message_id=message_id, sender=sender, proxy=proxy) self.user_auth = user_auth self.cookies = cookies - # ----------------------------------------------------------------------- - # DH key agreement & wrapping - # ----------------------------------------------------------------------- + # ======================================================================= + # MGK-specific crypto helpers + # ======================================================================= @classmethod - def derive_wrapping_key( - cls, encryption_key_16: bytes, hmac_key_32: bytes - ) -> bytes: - """Derive a 16-byte wrapping key from encryption and HMAC keys.""" + def derive_wrapping_key(cls, encryption_key_16: bytes, hmac_key_32: bytes) -> bytes: + """Derive a 16-byte wrapping key from the entity encryption + HMAC keys.""" if len(encryption_key_16) != 16: raise ValueError("encryptionKey must be 16 bytes") if len(hmac_key_32) != 32: @@ -245,7 +247,7 @@ class MSL_MGK(MSLBase): @staticmethod def int_to_unsigned_bytes(value: int) -> bytes: - """Encode a non-negative integer as big-endian unsigned bytes.""" + """Convert a non-negative integer to big-endian bytes (minimal length).""" if value < 0: raise ValueError("value must be non-negative") if value == 0: @@ -254,7 +256,7 @@ class MSL_MGK(MSLBase): @staticmethod def correct_null_bytes(value: bytes) -> bytes: - """Ensure at most one leading null byte (DH public-key encoding).""" + """Normalise leading null bytes: keep exactly one leading 0x00 if present.""" count = 0 for byte in value: if byte == 0: @@ -268,18 +270,14 @@ class MSL_MGK(MSLBase): @classmethod def dh_generate_keypair(cls) -> Tuple[int, bytes]: - """Generate a Diffie-Hellman key pair. Returns (private_key, public_key_bytes).""" - private_key = int.from_bytes(os.urandom(len(cls.DH_PRIME)), "big") % ( - cls.DH_P - 3 - ) + 2 + """Generate a DH keypair. Returns ``(private_key, public_key_wire)``.""" + private_key = int.from_bytes(os.urandom(len(cls.DH_PRIME)), "big") % (cls.DH_P - 3) + 2 public_key = pow(cls.DH_G, private_key, cls.DH_P) public_key_bytes = cls.int_to_unsigned_bytes(public_key) return private_key, cls.correct_null_bytes(public_key_bytes) @classmethod - def dh_compute_shared_secret_bytes( - cls, dh_private_key: int, server_public_key_wire: bytes - ) -> bytes: + def dh_compute_shared_secret_bytes(cls, dh_private_key: int, server_public_key_wire: bytes) -> bytes: """Compute the DH shared secret from our private key and the server's public key.""" normalized_server_public_key = cls.correct_null_bytes(server_public_key_wire) server_public_key_raw = ( @@ -295,9 +293,9 @@ class MSL_MGK(MSLBase): def kdf_authenticated_dh( cls, derivation_key: bytes, shared_secret_bytes: bytes ) -> Tuple[bytes, bytes, bytes]: - """Key derivation for AUTHENTICATED_DH. + """Derive encryption, HMAC, and wrapping keys from the DH shared secret. - Returns (encryption_key, hmac_key, next_derivation_key). + Returns ``(encryption_key, hmac_key, wrapping_key)``. """ if derivation_key is None: raise ValueError("derivation key is required for AUTHENTICATED_DH") @@ -313,15 +311,13 @@ class MSL_MGK(MSLBase): return encryption_key, hmac_key, wrapping_key - # ----------------------------------------------------------------------- - # MSL v1 encrypt / decrypt / sign / verify (MGK-specific) - # ----------------------------------------------------------------------- + # ======================================================================= + # MGK v1 encrypt / sign (entity-level, used during handshake only) + # ======================================================================= @staticmethod - def msl_encrypt_v1( - key_id: str, encryption_key_16: bytes, plaintext_bytes: bytes - ) -> bytes: - """Encrypt data using MSL v1 envelope format (AES-CBC).""" + def msl_encrypt_v1(key_id: str, encryption_key_16: bytes, plaintext_bytes: bytes) -> bytes: + """AES-CBC encrypt with MSL v1 envelope. Returns JSON-encoded bytes.""" iv = get_random_bytes(16) ciphertext = AES.new(encryption_key_16, AES.MODE_CBC, iv).encrypt( pad(plaintext_bytes, AES.block_size) @@ -336,43 +332,49 @@ class MSL_MGK(MSLBase): @staticmethod def msl_decrypt_v1(encryption_key_16: bytes, envelope_bytes: bytes) -> bytes: - """Decrypt an MSL v1 envelope (AES-CBC).""" + """AES-CBC decrypt an MSL v1 envelope. Returns unpadded plaintext 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 - ) + padded_plaintext = AES.new(encryption_key_16, AES.MODE_CBC, iv).decrypt(ciphertext) return unpad(padded_plaintext, AES.block_size) @staticmethod def msl_sign_b64(hmac_key_32: bytes, data_bytes: bytes) -> str: - """Compute an HMAC-SHA256 signature and return it base64-encoded.""" + """HMAC-SHA256 sign *data_bytes* and return base64-encoded signature.""" 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: - """Verify an HMAC-SHA256 signature. Raises :class:`ValueError` on mismatch.""" + def msl_verify_sig(hmac_key_32: bytes, data_bytes: bytes, signature_b64: str) -> None: + """Verify an MSL HMAC signature. Raises ``ValueError`` on mismatch.""" signer = HMAC.new(hmac_key_32, digestmod=SHA256) signer.update(data_bytes) expected_signature = signer.digest() received_signature = base64.b64decode(signature_b64) if expected_signature != received_signature: - raise ValueError( - "Response signature verification failed: HMAC mismatch" - ) + raise ValueError("Response signature verification failed: HMAC mismatch") - # ----------------------------------------------------------------------- - # Sidecar file helpers - # ----------------------------------------------------------------------- + # ======================================================================= + # KpeKph file / string loading helpers + # ======================================================================= + + @staticmethod + def b64_decode_strict(value: str) -> bytes: + """Strict base64 decode with URL-safe normalisation and padding fix.""" + normalized_value = value.strip().strip('"').strip("'") + normalized_value = normalized_value.replace('-', '+').replace('_', '/') + pad_needed = len(normalized_value) % 4 + if pad_needed == 2: + normalized_value += '==' + elif pad_needed == 3: + normalized_value += '=' + return base64.b64decode(normalized_value.encode("ascii"), validate=True) @classmethod def find_sidecar_file(cls, filename: str, env_name: str) -> Optional[Path]: - """Locate a sidecar file by checking an env var, CWD, and module dir.""" + """Search for *filename* in env-var, CWD, and script directory trees.""" env_value = os.getenv(env_name) candidates: List[Path] = [] @@ -380,11 +382,12 @@ class MSL_MGK(MSLBase): candidates.append(Path(env_value)) roots = [Path.cwd(), Path(__file__).resolve().parent] + for root in roots: candidates.append(root / filename) candidates.extend(root.glob(f"**/{filename}")) - seen: set[str] = set() + seen = set() for candidate in candidates: key = str(candidate.resolve()) if candidate.exists() else str(candidate) if key in seen: @@ -397,7 +400,7 @@ class MSL_MGK(MSLBase): @staticmethod def load_esnid_file(path: Path) -> str: - """Load an ESNID string from a text file.""" + """Read an ESNID string from a text file.""" value = path.read_text(encoding="utf-8", errors="ignore").strip() if not value: raise ValueError(f"Empty ESNID file: {path}") @@ -405,9 +408,9 @@ class MSL_MGK(MSLBase): @classmethod def load_kpe_kph_file(cls, path: Path) -> Tuple[bytes, bytes, bytes]: - """Load Kpe/Kph keys from a comma-separated base64 file. + """Load KpeKph from a comma-separated base64 file. - Returns (encryption_key, hmac_key, wrapping_key). + Returns ``(encryption_key, hmac_key, wrapping_key)``. """ raw = path.read_bytes() if raw.startswith(b"\xef\xbb\xbf"): @@ -427,238 +430,38 @@ class MSL_MGK(MSLBase): wrap_key = cls.derive_wrapping_key(enc_key, hmac_key) return enc_key, hmac_key, wrap_key - # ----------------------------------------------------------------------- - # Custom base64 decode (strict validation) - # ----------------------------------------------------------------------- - - @staticmethod - def b64_decode_strict(value: str) -> bytes: - """Base64-decode a string with strict validation.""" - normalized_value = value.strip().strip('"').strip("'") - return base64.b64decode(normalized_value.encode("ascii"), validate=True) - - # ----------------------------------------------------------------------- - # Custom generate_msg_header (json.dumps, custom languages, no recipient) - # ----------------------------------------------------------------------- - - @staticmethod - def generate_msg_header( - message_id: int, - sender: str, - is_handshake: bool, - userauthdata: Optional[dict] = None, - keyrequestdata: Optional[dict] = None, - compression: Optional[str] = "GZIP", - ) -> str: - """Generate an MSL message header for MGK. - - Uses ``json.dumps`` (not jsonpickle) with compact separators, a - single-language list, and no ``recipient`` field. - """ - header_data: Dict[str, Any] = { - "messageid": message_id, - "renewable": True, - "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=(",", ":")) - - # ----------------------------------------------------------------------- - # AUTHENTICATED_DH handshake - # ----------------------------------------------------------------------- - @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_MGK": - """Perform an AUTHENTICATED_DH key exchange and return a configured instance.""" - endpoint = "https://www.netflix.com/msl/playapi/cadmium/licensedmanifest/1" - message_id = random.randint(0, pow(2, 52)) + def parse_kpe_kph_string(cls, raw_string: str) -> Tuple[bytes, bytes, bytes]: + """Parse a raw KpeKph string (e.g. from a CLI argument). - 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) + Supports both ':' and ',' as separator between the Kpe and Kph + base64-encoded values. + """ + text = raw_string.strip() + if ':' in text: + left, right = text.split(':', 1) + elif ',' in text: + left, right = text.split(',', 1) 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]}" + raise ValueError( + "KpeKph string must contain ':' or ',' separator " + "between Kpe and Kph values" ) - parsed_response = cls.parse_concatenated_json(response.text) - if not parsed_response: - raise RuntimeError("Key exchange failed: empty MSL response") + enc_key = cls.b64_decode_strict(left.strip()) + hmac_key = cls.b64_decode_strict(right.strip()) - key_exchange = parsed_response[0] + 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)}") - if "errordata" in key_exchange: - decoded_error = base64.b64decode(key_exchange["errordata"]).decode( - "utf-8", "ignore" - ) - raise RuntimeError(f"Key exchange failed: {decoded_error}") + wrap_key = cls.derive_wrapping_key(enc_key, hmac_key) + return enc_key, hmac_key, wrap_key - if "headerdata" not in key_exchange: - raise RuntimeError( - f"Key exchange failed: missing headerdata in response: " - f"{str(key_exchange)[:500]}" - ) - - header_json = json.loads( - base64.b64decode(key_exchange["headerdata"]).decode("utf-8") - ) - 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, - ) - - # ----------------------------------------------------------------------- + # ======================================================================= # Platform-specific request headers - # ----------------------------------------------------------------------- + # ======================================================================= @staticmethod def build_request_headers( @@ -684,88 +487,259 @@ class MSL_MGK(MSLBase): "x-netflix.client.netjs.version": MSL_MGK.DEFAULT_NETJS_VERSION, "x-netflix.client.last-interacted-days": "0", } + if expiry_timeout is not None: headers["x-netflix.request.expiry.timeout"] = str(expiry_timeout) + if referer: headers["Referer"] = referer + if viewable_id is not None: headers["x-netflix.playback.main-content-viewable-id"] = str(viewable_id) + if profile_guid: headers["x-netflix.client.current-profile-guid"] = profile_guid + if esn: headers["x-netflix.client.ftl.esn"] = esn + if extra_headers: headers.update(extra_headers) + return headers - # -- Manifest defaults --------------------------------------------------- + # ======================================================================= + # Manifest defaults + # ======================================================================= @staticmethod def manifest_request_defaults() -> Tuple[str, Dict[str, str]]: """Return the default manifest endpoint and query params for MGK.""" return MSL_MGK.DEFAULT_MANIFEST_ENDPOINT, dict(MSL_MGK.DEFAULT_MANIFEST_PARAMS) - # ----------------------------------------------------------------------- - # MGK-specific send_message - # ----------------------------------------------------------------------- + # ======================================================================= + # Handshake -- MGK AUTHENTICATED_DH key exchange + # ======================================================================= - 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, + @classmethod + def handshake( + cls, + session: requests.Session, + sender: str, + kpekph_path: Optional[Union[str, Path]] = None, + kpekph_raw: Optional[str] = None, + msl_keys_path: Optional[Union[str, Path]] = None, + cookies: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None, + proxy: Optional[Dict[str, str]] = None, timeout: int = 30, - ) -> Tuple[Dict[str, Any], Any]: - """Send an MSL message via the MGK flow. + new_msl: bool = False, + ) -> "MSL_MGK": + """Perform an MGK (AUTHENTICATED_DH) key exchange. - Raises :class:`RuntimeError` on MSL errors. + Unlike Android/iOS/TV which return raw ``MSLKeys``, this method + returns a fully constructed :class:`MSL_MGK` instance ready for + ``send_message()`` calls. This is because the MGK handshake uses + entity-level encryption (KpeKph) that is not available after the + handshake completes. """ - 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, + _log.info("MGK handshake: sender=%s", sender) + endpoint = cls.DEFAULT_HANDSHAKE_ENDPOINT + message_id = random.randint(0, pow(2, 52)) + + if cookies: + session.cookies.update(cookies) + + # ---- Check cache --------------------------------------------------- + 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: + _log.info("Reusing cached MGK MSL keys") + return cls( + session=session, + keys=cached_keys, + message_id=message_id, + sender=sender, + cookies=cookies, + proxy=proxy, + ) + + if not sender: + raise RuntimeError("Missing sender or ESNID for MGK handshake") + + _log.info("Performing fresh MGK key exchange") + # ---- Resolve KpeKph ------------------------------------------------ + if kpekph_raw: + _log.debug("KpeKph source: raw string") + entity_encryption_key, entity_hmac_key, entity_wrapping_key = cls.parse_kpe_kph_string(kpekph_raw) + elif kpekph_path: + _log.debug("KpeKph source: file %s", kpekph_path) + resolved_kpekph_path = Path(kpekph_path) + entity_encryption_key, entity_hmac_key, entity_wrapping_key = cls.load_kpe_kph_file( + resolved_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 + ) + + # ---- Build key request data ---------------------------------------- + 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() + _log.debug("DH keypair generated (mechanism=%s)", mechanism) + 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__ + + # ---- Encrypt header with entity keys (MGK v1) ---------------------- + header_plaintext = cls.generate_msg_header( + message_id=message_id, + sender=sender, + is_handshake=True, + keyrequestdata=key_request_data, + compression="GZIP", + languages=["en-US"], + ).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), + } ) + # ---- Send handshake request ---------------------------------------- + _log.debug("MGK handshake request → %s", endpoint) + response = session.post( + url=endpoint, + data=request_body, + headers=headers or {}, + timeout=timeout, + ) + _log.debug("MGK handshake response ← HTTP %d", response.status_code) + 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}" + f"Key exchange failed: HTTP {response.status_code} {response.text[:500]}" ) - header, payload_data = self.parse_message(response) + parsed_response = cls.parse_concatenated_json(response.text) - if "errordata" in header: - decoded_error = json.loads( - base64.b64decode(header["errordata"]).decode("utf-8") + 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]}" ) - raise RuntimeError(f"MSL response contains an error: {decoded_error}") - return header, payload_data + # ---- Derive session keys from response ----------------------------- + 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") - # ----------------------------------------------------------------------- - # MGK-specific create_message (single payload chunk) - # ----------------------------------------------------------------------- + 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"] + msl_keys.key_scheme = key_response_data.get("scheme") + msl_keys.key_mechanism = response_key_data.get("mechanism") + msl_keys.server_public_key_b64 = response_key_data.get("publickey") + msl_keys.userauthdata = header_json.get("userauthdata") + msl_keys.useridtoken = header_json.get("useridtoken") + + if cache_path: + cls.cache_keys(msl_keys, cache_path) + _log.info("MGK key exchange complete, session keys derived") + + return cls( + session=session, + keys=msl_keys, + message_id=message_id, + sender=sender, + cookies=cookies, + proxy=proxy, + ) + + # ======================================================================= + # Override create_message -- MGK uses single payload chunk format + # ======================================================================= def create_message( self, application_data: Dict[str, Any], userauthdata: Optional[dict] = None, ) -> str: - """Build an MSL request message with a **single** payload chunk. + """Build an MSL message with a single payload chunk. - Unlike the base class which splits data across two chunks - (data + end-of-msg), MGK puts everything in one chunk with - ``endofmsg=True``. + The MGK endpoint expects the payload data and ``endofmsg`` flag in + a single chunk, unlike the Android/iOS/TV endpoints which use two + separate chunks (data + endofmsg). """ self.message_id += 1 @@ -775,14 +749,12 @@ class MSL_MGK(MSLBase): sender=self.sender, is_handshake=False, userauthdata=userauthdata, - compression="GZIP", ) ) + message = json.dumps( { - "headerdata": base64.b64encode(header_data.encode("utf-8")).decode( - "utf-8" - ), + "headerdata": base64.standard_b64encode(header_data.encode("utf-8")).decode("utf-8"), "signature": self.sign(header_data).decode("utf-8"), "mastertoken": self.keys.mastertoken, }, @@ -792,6 +764,7 @@ class MSL_MGK(MSLBase): compressed_application_data = self.gzip_compress( json.dumps(application_data, separators=(",", ":")).encode("utf-8") ).decode("utf-8") + payload_chunk = self.encrypt( json.dumps( { @@ -806,9 +779,7 @@ class MSL_MGK(MSLBase): ) message += json.dumps( { - "payload": base64.b64encode(payload_chunk.encode("utf-8")).decode( - "utf-8" - ), + "payload": base64.standard_b64encode(payload_chunk.encode("utf-8")).decode("utf-8"), "signature": self.sign(payload_chunk).decode("utf-8"), }, separators=(",", ":"), @@ -816,149 +787,38 @@ class MSL_MGK(MSLBase): return message - # ----------------------------------------------------------------------- - # MGK-specific parse_message (accepts response objects) - # ----------------------------------------------------------------------- + # ======================================================================= + # Cache I/O overrides (MGK-specific MSLKeys with extra fields) + # ======================================================================= - def parse_message(self, response: Any) -> Tuple[Dict[str, Any], Any]: - """Parse an MSL response. - - Accepts either a :class:`requests.Response` object or a raw string. - Raises :class:`RuntimeError` if the response is empty or not valid - concatenated JSON. - """ - if hasattr(response, "text"): - message = response.text or "" - raw_content = response.content - 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} " - f"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 - - # ----------------------------------------------------------------------- - # MGK-specific decrypt_payload_chunks (raises on error) - # ----------------------------------------------------------------------- - - def decrypt_payload_chunks( - self, payload_chunks: List[Dict[str, str]] - ) -> Any: - """Decrypt MSL payload chunks. Raises :class:`RuntimeError` on error.""" - if not self.keys.encryption: - raise ValueError("Encryption key is not available") - - 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"] - - # ----------------------------------------------------------------------- - # MGK-specific encrypt / sign (use aes_cbc_encrypt from base) - # ----------------------------------------------------------------------- - - def encrypt(self, plaintext: str) -> str: - """Encrypt plaintext using the negotiated AES-CBC key.""" - if not self.keys.encryption: - raise ValueError("Encryption key is not available") - if not self.keys.mastertoken: - raise ValueError("Master token is not available") - - iv = os.urandom(16) - token_data = json.loads( - base64.b64decode(self.keys.mastertoken["tokendata"]).decode("utf-8") - ) - 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: - """Sign text using the negotiated HMAC key.""" - if not self.keys.sign: - raise ValueError("Sign key is not available") - - signer = HMAC.new(self.keys.sign, digestmod=SHA256) - signer.update(text.encode("utf-8")) - return base64.b64encode(signer.digest()) - - # ----------------------------------------------------------------------- - # Cache I/O override (checks expiration, not renewalwindow) - # ----------------------------------------------------------------------- - - @classmethod - def load_cache_data(cls, msl_keys_path: Optional[Path] = None) -> Optional[MSLKeys]: - """Load cached keys, checking the ``expiration`` field in the token.""" + @staticmethod + def load_cache_data(msl_keys_path: Optional[Path] = None) -> Optional[MSLKeys]: + """Load cached MGK keys, ensuring all MGK-specific fields exist.""" 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 from a base MSLKeys cache, it won't have MGK fields + if not hasattr(loaded_keys, "wrapdata"): + loaded_keys.wrapdata = None + if not hasattr(loaded_keys, "derivation_key"): + loaded_keys.derivation_key = None + if not hasattr(loaded_keys, "key_scheme"): + loaded_keys.key_scheme = None + if not hasattr(loaded_keys, "key_mechanism"): + loaded_keys.key_mechanism = None + if not hasattr(loaded_keys, "server_public_key_b64"): + loaded_keys.server_public_key_b64 = None + if not hasattr(loaded_keys, "userauthdata"): + loaded_keys.userauthdata = None + if not hasattr(loaded_keys, "useridtoken"): + loaded_keys.useridtoken = None + # Check token expiry using expiration field (MGK uses "expiration" + # instead of "renewalwindow" because the token is obtained through + # entity auth, not the standard NONE/RSA/WV handshake) if loaded_keys.mastertoken: expiry_value = json.loads( base64.b64decode(loaded_keys.mastertoken["tokendata"]).decode("utf-8") @@ -966,20 +826,79 @@ class MSL_MGK(MSLBase): 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 - ) + hours_remaining = (expiry - datetime.now(timezone.utc)).total_seconds() / 3600 + if hours_remaining < 10: return None - if not hasattr(loaded_keys, "wrapdata"): - loaded_keys.wrapdata = None - if not hasattr(loaded_keys, "derivation_key"): - loaded_keys.derivation_key = None - return loaded_keys - # cache_keys inherited from MSLBase (identical implementation) + # ======================================================================= + # Override send_message to add PBO normalisation and cookies + # ======================================================================= + + def send_message( + self, + endpoint: str, + params: Dict[str, str], + application_data: Dict[str, Any], + userauthdata: Optional[dict] = None, + headers: Optional[dict] = None, + proxy: Optional[Dict[str, str]] = None, + timeout: int = 30, + ) -> Tuple[Dict[str, Any], Any]: + """Send an MSL message with PBO payload normalisation and MGK cookies.""" + normalized = self.normalize_application_data(endpoint, application_data) + message = self.create_message(normalized, userauthdata) + + request_kwargs: Dict[str, Any] = { + "url": endpoint, + "data": message, + "params": params, + "headers": headers, + "timeout": timeout, + } + effective_proxy = proxy or self.proxy + if effective_proxy: + request_kwargs["proxies"] = effective_proxy + if self.cookies: + request_kwargs["cookies"] = self.cookies + + res = self.session.post(**request_kwargs) + + if res.status_code != 200: + raise RuntimeError( + f"MSL request failed with HTTP {res.status_code}: {res.text[:500]}" + ) + + response_text = res.text or "" + stripped = response_text.lstrip() + if not stripped: + raise RuntimeError("MSL request failed: empty response body") + if not stripped.startswith("{"): + raise RuntimeError( + "MSL request failed: the server did not return concatenated MSL JSON. " + f"Content-Type: {res.headers.get('content-type', '')!r}. " + f"Body preview: {response_text[:500]!r}" + ) + + header, payload_data = self.parse_message(response_text) + if not header: + raise RuntimeError( + f"MSL request failed: parsed response does not contain a header. " + f"Body preview: {response_text[:500]!r}" + ) + if "errordata" in header: + decoded_error = json.loads( + base64.standard_b64decode(header["errordata"].encode("utf-8")).decode("utf-8") + ) + raise RuntimeError(f"MSL response contains an error: {decoded_error}") + + return header, payload_data + + +# Initialise the DH prime as an integer after the class body +MSL_MGK.DH_P = int.from_bytes(MSL_MGK.DH_PRIME, "big") __all__ = [ diff --git a/modules/msl_tv.py b/modules/msl/tv.py similarity index 95% rename from modules/msl_tv.py rename to modules/msl/tv.py index 68d7bae..b088355 100644 --- a/modules/msl_tv.py +++ b/modules/msl/tv.py @@ -2,8 +2,11 @@ from __future__ import annotations import base64 import json +import logging import random from pathlib import Path + +_log = logging.getLogger(__name__) from typing import Any, Dict, List, Optional, Tuple import jsonpickle @@ -13,7 +16,7 @@ from Cryptodome.PublicKey import RSA from Cryptodome.PublicKey.RSA import RsaKey from pywidevine import Cdm as WidevineCdm, Device as WidevineDevice, PSSH -from .msl_base import MSLBase, MSLKeys as _BaseMSLKeys, get_widevine_key +from .base import MSLBase, MSLKeys as _BaseMSLKeys, get_widevine_key # --------------------------------------------------------------------------- @@ -92,8 +95,9 @@ class MSL_TV(MSLBase): sender: str, user_auth: Optional[dict] = None, drm: str = "widevine", + proxy: Optional[Dict[str, str]] = None, ) -> None: - super().__init__(session=session, keys=keys, message_id=message_id, sender=sender) + super().__init__(session=session, keys=keys, message_id=message_id, sender=sender, proxy=proxy) self.user_auth = user_auth self.drm = drm @@ -114,19 +118,23 @@ class MSL_TV(MSLBase): headers: Optional[Dict[str, str]] = None, ) -> MSLKeys: """Perform a key exchange using Widevine (if CDM available) or RSA.""" + _log.info("TV MSL handshake: sender=%s, drm=%s", sender, drm) if cookies: session.cookies.update(cookies) cache_path = Path(msl_keys_path) msl_keys = cls.load_cache_data(cache_path) if msl_keys is not None and not new_msl: + _log.info("Reusing cached MSL keys") return msl_keys + _log.info("Performing fresh key exchange") message_id = random.randint(0, pow(2, 52)) msl_keys = MSLKeys() # ---- Choose DRM scheme --------------------------------------------- if not cdm and drm == "widevine": + _log.debug("No CDM provided — falling back to RSA key exchange") # No CDM provided – fall back to RSA key exchange msl_keys.rsa = RSA.generate(2048) assert msl_keys.rsa is not None @@ -141,6 +149,7 @@ class MSL_TV(MSLBase): }, } elif drm == "widevine": + _log.debug("Using Widevine DRM for key exchange") # CDM available – use Widevine if not isinstance(cdm, WidevineCdm): device = WidevineDevice.load(cdm_device) @@ -204,9 +213,11 @@ class MSL_TV(MSLBase): host="nrdp25.prod.ftl.netflix.com", language="en-US,en-PH,en", ) + _log.debug("TV handshake request → %s", handshake_endpoint) res = session.post( url=handshake_endpoint, data=data, headers=handshake_headers, timeout=30 ) + _log.debug("TV handshake response ← HTTP %d", res.status_code) if res.status_code != 200: raise RuntimeError( @@ -274,6 +285,7 @@ class MSL_TV(MSLBase): msl_keys.mastertoken = key_response_data["mastertoken"] cls.cache_keys(msl_keys, cache_path) + _log.info("TV key exchange complete") return msl_keys # -- Platform-specific request headers ----------------------------------- diff --git a/modules/msl_web.py b/modules/msl/web.py similarity index 95% rename from modules/msl_web.py rename to modules/msl/web.py index 717cbbd..13335c8 100644 --- a/modules/msl_web.py +++ b/modules/msl/web.py @@ -2,8 +2,11 @@ from __future__ import annotations import base64 import json +import logging import random from collections import OrderedDict + +_log = logging.getLogger(__name__) from datetime import datetime, timezone from http.cookiejar import CookieJar from pathlib import Path @@ -15,7 +18,7 @@ from Cryptodome.Cipher import PKCS1_OAEP from Cryptodome.PublicKey import RSA from Cryptodome.PublicKey.RSA import RsaKey -from .msl_base import MSLBase, MSLKeys as _BaseMSLKeys +from .base import MSLBase, MSLKeys as _BaseMSLKeys # --------------------------------------------------------------------------- @@ -65,8 +68,9 @@ class MSL_WEB(MSLBase): message_id: int, sender: str, user_auth: Optional[dict] = None, + proxy: Optional[Dict[str, str]] = None, ) -> None: - super().__init__(session=session, keys=keys, message_id=message_id, sender=sender) + super().__init__(session=session, keys=keys, message_id=message_id, sender=sender, proxy=proxy) self.user_auth = user_auth # -- RSA handshake ------------------------------------------------------- @@ -83,17 +87,21 @@ class MSL_WEB(MSLBase): headers: Optional[Dict[str, str]] = None, ) -> MSLKeys: """Perform an RSA (ASYMMETRIC_WRAPPED) key exchange.""" + _log.info("Web RSA handshake: sender=%s", sender) 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: + _log.info("Reusing cached MSL keys") return cached + _log.info("Performing fresh RSA key exchange") message_id = random.randint(0, 2**52) keys = MSLKeys() keys.rsa = RSA.generate(2048) + _log.debug("Generated RSA-2048 ephemeral keypair") keyrequestdata = { "scheme": "ASYMMETRIC_WRAPPED", @@ -141,12 +149,14 @@ class MSL_WEB(MSLBase): separators=(",", ":"), ) + _log.debug("Web handshake request → %s", endpoint or cls.DEFAULT_HANDSHAKE_ENDPOINT) response = session.post( url=endpoint or cls.DEFAULT_HANDSHAKE_ENDPOINT, data=envelope, headers=headers or cls.build_request_headers(request_name="aleProvision"), timeout=30, ) + _log.debug("Web handshake response ← HTTP %d", response.status_code) if response.status_code != 200: raise RuntimeError( f"Key exchange failed: HTTP {response.status_code} {response.text[:500]}" @@ -190,6 +200,7 @@ class MSL_WEB(MSLBase): keys.mastertoken = header_json["keyresponsedata"]["mastertoken"] cls.cache_keys(keys, cache_path) + _log.info("Web RSA key exchange complete") return keys # -- Platform-specific request headers ----------------------------------- diff --git a/modules/platforms/__init__.py b/modules/platforms/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/modules/platforms/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/modules/platforms/android.py b/modules/platforms/android.py new file mode 100644 index 0000000..9f57a44 --- /dev/null +++ b/modules/platforms/android.py @@ -0,0 +1,386 @@ +from __future__ import annotations +import json +import random +import sys +import time +from pathlib import Path +from typing import Optional +from urllib.parse import quote +from pywidevine import Cdm as WidevineCdm, Device as WidevineDevice +from modules.msl.android import MSL_ANDROID +from modules.helpers import ( + ensure_output_dir, restore_auth_cookies, get_nfvdid, get_flow_session_cookies, + save_session_cookies, + generate_netflix_uuid, generate_request_id, generate_esn_random_suffix, + decrypt_msl_header, extract_clcs_session_id, extract_rendition_id, +) +from modules.config import setup_config +from modules.logging import setup_logger +from modules.session import setup_session + +config = setup_config() +EMAIL = config["NETFLIX"]["EMAIL"] +PASSWORD = config["NETFLIX"]["PASSWORD"] + + +def run_android(wvd_path: Path, + new_msl: bool = False, no_verify: bool = False, + proxy: Optional[str] = None): + log = setup_logger('ANDROID MSL') + OUTPUT_DIR = ensure_output_dir("android") + + 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 = not no_verify + RESTORE_AUTH_COOKIES = False + + 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) + _sid = widevine_device.system_id + MSL_CACHE_PATH = OUTPUT_DIR / f"msl_keys_cache_android_{_sid}.json" + AUTH_COOKIES_PATH = OUTPUT_DIR / f"netflix_auth_cookies_{_sid}.json" + USERIDTOKEN_PATH = OUTPUT_DIR / f"netflix_auth_useridtoken_{_sid}.json" + TOKENS_OUTPUT_PATH = OUTPUT_DIR / f"netflix_auth_tokens_{_sid}.json" + + ESN = f"NFANDROID1-PRV-P-SAMSUSM-F711N-{_sid}-{generate_esn_random_suffix(64)}" + log.info("ESN: %s", ESN) + + REQUEST_CLIENT_CONTEXT_UNKNOWN = '{"appView":"unknown","appState":"foreground"}' + APPBOOT_REQUEST_CLIENT_CONTEXT = '{"appView":"unknown","appState":"foreground"}' + + session = setup_session(verify_tls=VERIFY_TLS, proxy=proxy) + _proxy = {"http": proxy, "https": proxy} if proxy else None + + if RESTORE_AUTH_COOKIES: + restore_auth_cookies(session, AUTH_COOKIES_PATH, log) + + 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 = get_nfvdid(session, response) + + log.info("Initial nfvdid cookie obtained") + + log.info("Starting MSL Widevine exchange") + + 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": ( + generate_netflix_uuid() + ), + "x-netflix.androidapi": "35", + "x-netflix.deviceformfactor": "PHONE", + "x-netflix.devicememorylevel": "HIGH", + "x-netflix.request.attempt": "1", + "x-netflix.request.id": generate_request_id(), + "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", + proxy=_proxy, + ) + + nfvdid, flow_session_id = get_flow_session_cookies(session) + + log.info("MSL Widevine exchange completed") + + log.info("Loading login page") + response = session.get(LOGIN_URL, timeout=30) + response.raise_for_status() + login_html = response.text + + 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") + + 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", + } + + _THROTTLE_RETRIES = 3 + _THROTTLE_WAIT = 60 + _clcs_attempted = False + + for _attempt in range(1, _THROTTLE_RETRIES + 2): # +1 slot for the CLCS retry + 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) + + _error_code = None + 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") + ) + + if _error_code == "throttling_failure" and _attempt < _THROTTLE_RETRIES: + log.warning("Throttled by Netflix (attempt %d/%d), retrying in %ds...", _attempt, _THROTTLE_RETRIES, _THROTTLE_WAIT) + time.sleep(_THROTTLE_WAIT) + continue + + elif _error_code == "incorrect_password" and not _clcs_attempted: + # Samurai rejected credentials without auth cookies — fall back to CLCS + # web login to obtain NetflixId/SecureNetflixId, then retry once. + log.warning("incorrect_password — falling back to CLCS web login") + clcs_session_id = extract_clcs_session_id(login_html) + rendition_id = extract_rendition_id(login_html) + if not clcs_session_id or not rendition_id: + log.error("Cannot extract CLCS session IDs from login page for fallback") + sys.exit(1) + clcs_resp = session.post( + "https://web.prod.cloud.netflix.com/graphql", + json={ + "operationName": "CLCSScreenUpdate", + "variables": { + "format": "HTML", + "imageFormat": "PNG", + "locale": "en-US", + "serverState": json.dumps({ + "realm": "growth", + "name": "PASSWORD_LOGIN", + "clcsSessionId": clcs_session_id, + "sessionContext": { + "session-breadcrumbs": {"funnel_name": "loginWeb"}, + "login.navigationSettings": {"hideOtpToggle": True}, + }, + }, separators=(",", ":")), + "serverScreenUpdate": json.dumps({ + "realm": "custom", + "name": "growthLoginByPassword", + "metadata": {"recaptchaSiteKey": "6Lf8hrcUAAAAAIpQAFW2VFjtiYnThOjZOA5xvLyR"}, + "loggingAction": "Submitted", + "loggingCommand": "SubmitCommand", + "referrerRenditionId": rendition_id, + }, 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": ""}}, + ], + }, + "extensions": {"persistedQuery": {"id": "1c276cdf-caef-49cf-b38e-384972c2b47e", "version": 102}}, + }, + headers={ + "User-Agent": USER_AGENT, + "Accept": "application/json", + "Content-Type": "application/json", + "Origin": "https://www.netflix.com", + "Referer": LOGIN_URL, + }, + timeout=30, + ) + if "errors" in clcs_resp.json(): + log.error("CLCS fallback login failed: %s", clcs_resp.json().get("errors")) + sys.exit(1) + session.get("https://www.netflix.com/browse", timeout=30) + _fresh = session.cookies.get_dict() + confirm_login_query["netflixId"] = _fresh.get("NetflixId", "") + confirm_login_query["secureNetflixId"] = _fresh.get("SecureNetflixId", "") + confirm_login_query["flwssn"] = _fresh.get("flwssn", flow_session_id) + _clcs_attempted = True + log.info("CLCS fallback complete, retrying VerifyLoginMslRequest") + continue + + elif _error_code: + log.error("Login errorCode: %s", _error_code) + sys.exit(1) + break + + if "headerdata" not in confirm_login_header: + log.critical("Missing 'headerdata' in MSL response") + sys.exit(1) + + try: + header_data = decrypt_msl_header(confirm_login_header["headerdata"], msl_client.keys.encryption, msl_client.keys.sign) + 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) + + try: + auth_cookies = save_session_cookies(session, AUTH_COOKIES_PATH, log) + except Exception: + sys.exit(1) + + result = { + "useridtoken": tokens, + "auth_cookies": auth_cookies, + "header_data": header_data, + } + + log.info("VerifyLoginMslRequest succeeded") + # print(json.dumps(result, indent=2)) diff --git a/modules/platforms/android_rsa.py b/modules/platforms/android_rsa.py new file mode 100644 index 0000000..8e71ee5 --- /dev/null +++ b/modules/platforms/android_rsa.py @@ -0,0 +1,201 @@ +from __future__ import annotations +import json +import random +import re +import sys +from pathlib import Path +from typing import Optional +from urllib.parse import quote +from modules.msl.android import MSL_ANDROID +from modules.helpers import ( + ensure_output_dir, get_nfvdid, + generate_hex_id, +) +from modules.config import setup_config +from modules.logging import setup_logger +from modules.session import setup_session + +config = setup_config() +EMAIL = config["NETFLIX"]["EMAIL"] +PASSWORD = config["NETFLIX"]["PASSWORD"] + + +def run_android_rsa(new_msl: bool = False, no_verify: bool = False, + proxy: Optional[str] = None): + logger = setup_logger('ANDROID MSL RSA') + output_dir = ensure_output_dir("android") + msl_cache_path = output_dir / "msl_keys_cache_android_rsa.json" + auth_cookies_path = output_dir / "netflix_auth_cookies_rsa.json" + useridtoken_path = output_dir / "netflix_auth_useridtoken_rsa.json" + tokens_output_path = output_dir / "netflix_auth_tokens_rsa.json" + + # NFCDCH-02-* ESN is accepted by the Android FTL endpoint without a WVD + esn = f"NFCDCH-02-{''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(32))}" + user_agent = f"com.netflix.mediaclient/63988 (Linux; U; Android 15; en_US; SM-F711N; Build/AP3A.240905.015.A2; Cronet/143.0.7445.0)" + device_model = "SM-F711N" + + session = setup_session(verify_tls=not no_verify, proxy=proxy) + _proxy = {"http": proxy, "https": proxy} if proxy else None + + response = session.post( + "https://android15.appboot.netflix.com/appboot/NFANDROID1-PRV-P-", + params={"keyVersion": "1"}, + headers={ + "Host": "android15.appboot.netflix.com", + "X-Netflix.Request.Client.Context": '{"appView":"unknown","appState":"foreground"}', + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": user_agent, + "Accept-Encoding": "gzip, deflate, br", + }, + timeout=30, + ) + nfvdid = get_nfvdid(session, response) + logger.info("Initial nfvdid cookie obtained") + + msl_headers = MSL_ANDROID.build_request_headers( + request_name="getProxyEsn", + user_agent=user_agent, + referer=None, + esn=esn, + expiry_timeout=12750, + host="android15.prod.cloud.netflix.com", + language="en-US,en", + device_model=quote(device_model, safe=""), + extra_headers={ + "Accept-Encoding": "gzip, deflate, br", + "Content-Encoding": "msl_v1", + "x-netflix.zuul.brotli.allowed": "true", + "x-netflix.appver": "9.60.0", + "x-netflix.clienttype": "samurai", + "x-netflix.request.client.context": '{"appView":"unknown","appState":"foreground"}', + "x-netflix.esnprefix": "NFANDROID1-PRV-P-", + "x-netflix.request.uuid": f"{generate_hex_id(8)}-{generate_hex_id(4)}-{generate_hex_id(4)}-{generate_hex_id(4)}-{generate_hex_id(12)}", + "x-netflix.androidapi": "35", + "x-netflix.deviceformfactor": "PHONE", + "x-netflix.devicememorylevel": "HIGH", + "x-netflix.request.attempt": "1", + "x-netflix.request.id": generate_hex_id(32), + "Content-Type": "application/json", + "x-netflix.client.request.name": "getProxyEsn", + "x-netflix.request.routing": '{"path":"\\/nq\\/android\\/playback\\/~1.0.0\\/router"}', + "user-agent": user_agent, + }, + ) + + logger.info("Performing RSA/ASYMMETRIC_WRAPPED MSL handshake (no WVD needed)") + msl_keys = MSL_ANDROID.rsa_handshake( + msl_keys_path=str(msl_cache_path), + session=session, + sender=esn, + new_msl=new_msl, + cookies={"nfvdid": nfvdid}, + endpoint="https://android.prod.ftl.netflix.com/nq/androidui/pbo_license/~1.0.0/router", + headers=msl_headers, + ) + + msl_client = MSL_ANDROID( + session=session, + keys=msl_keys, + message_id=random.randint(0, 2**52), + sender=esn, + drm="widevine", + proxy=_proxy, + ) + + logger.info("MSL RSA key exchange completed") + + # The NFCDCH-02-* ESN triggers the web CLCS auth flow (not samurai useridtoken). + # After the MSL handshake the HTTP session carries Netflix cookies, so we use + # the same CLCSScreenUpdate GraphQL path that run_web() uses. + logger.info("Fetching login page and extracting CLCS session context") + login_response = session.get("https://www.netflix.com/login", timeout=30) + login_html = login_response.text + + clcs_session_id = None + rendition_id = None + patterns = [ + r'clcsSessionId[\\"\'": ]+([0-9a-f\-]{36})', + r'(? None: + self._ssl_context = ssl_context + super().__init__(*args, **kwargs) + + def init_poolmanager(self, *args: Any, **kwargs: Any) -> None: + if self._ssl_context is None: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.load_verify_locations(cafile=certifi.where()) + ctx.check_hostname = True + ctx.verify_mode = ssl.CERT_REQUIRED + self._ssl_context = ctx + else: + ctx = self._ssl_context + kwargs["ssl_context"] = ctx + super().init_poolmanager(*args, **kwargs) + + def cert_verify(self, conn: Any, url: str, verify: bool, cert: Optional[Any]) -> None: + pass + + +def setup_session( + verify_tls: bool = True, + proxy: Optional[str] = None, +) -> requests.Session: + _log.debug("Creating session (TLS verify=%s, proxy=%s)", verify_tls, proxy) + session = requests.Session() + if verify_tls: + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_ctx.load_verify_locations(cafile=certifi.where()) + ssl_ctx.check_hostname = True + ssl_ctx.verify_mode = ssl.CERT_REQUIRED + session.verify = certifi.where() + session.mount("https://", CertifiAdapter(ssl_context=ssl_ctx)) + else: + session.verify = False + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + if proxy: + session.proxies.update({"http": proxy, "https": proxy}) + _log.debug("Proxy configured: %s", proxy) + session.headers.update({ + "User-Agent": "Mozilla/5.0", + "Accept": "*/*", + }) + return session