refactored

This commit is contained in:
Pari Malam
2026-05-30 10:52:45 +08:00
parent 9345cd8501
commit ffa26cd8bf
18 changed files with 4505 additions and 4119 deletions
+101 -3564
View File
File diff suppressed because it is too large Load Diff
+60 -26
View File
@@ -1,47 +1,81 @@
"""
logging.py — Colored logging setup for the NF-MSL project.
logging.py — Centralized colored logging for the NF-MSL project.
All loggers in this project propagate to the root logger, which is configured
once with coloredlogs. Named loggers (platform runners, MSL modules) only need
their level set — they inherit formatting and color from the root handler.
Set the MSL_DEBUG=1 environment variable to enable DEBUG-level output.
"""
from __future__ import annotations
import logging
import os
from typing import Optional
try:
import coloredlogs
COLOREDLOGS_AVAILABLE = True
_COLOREDLOGS = True
except ImportError:
COLOREDLOGS_AVAILABLE = False
_COLOREDLOGS = False
_ROOT_CONFIGURED = False
# ---------------------------------------------------------------------------
# Color scheme
# ---------------------------------------------------------------------------
LEVEL_STYLES: dict = {
"debug": {"color": "blue"},
"info": {"color": "green"},
"warning": {"color": "yellow", "bold": True},
"error": {"color": "red", "bold": True},
"critical": {"color": "magenta", "bold": True},
}
FIELD_STYLES: dict = {
"name": {"color": "cyan", "bold": True},
"levelname": {"color": "white", "bold": True},
"asctime": {"color": "white"},
"message": {},
}
DEFAULT_FMT = "%(name)s - %(levelname)s - %(message)s"
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def setup_logger(
name: str,
level: int = logging.INFO,
fmt: Optional[str] = None,
) -> 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
logger.setLevel(level)
return logger
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
@@ -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
+15 -2
View File
@@ -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")
+14 -2
View File
@@ -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:
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -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 -----------------------------------
+13 -2
View File
@@ -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 -----------------------------------
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+386
View File
@@ -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))
+201
View File
@@ -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'(?<!clcs)renditionId[\\"\'": ]+([0-9a-f\-]{36})',
]
for pattern in patterns:
match = re.search(pattern, login_html)
if match:
if not clcs_session_id:
clcs_session_id = match.group(1)
elif not rendition_id:
rendition_id = match.group(1)
if not clcs_session_id or not rendition_id:
logger.error("Could not extract CLCS session IDs from login page")
sys.exit(1)
logger.info("Submitting credentials via CLCSScreenUpdate (web flow)")
full_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": ""}},
],
}
response = session.post(
"https://web.prod.cloud.netflix.com/graphql",
json={
"operationName": "CLCSScreenUpdate",
"variables": full_variables,
"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": "https://www.netflix.com/login",
},
timeout=30,
)
login_resp_json = response.json()
if "errors" in login_resp_json:
logger.error("CLCSScreenUpdate failed: %s", login_resp_json["errors"])
sys.exit(1)
# Check login result
data = login_resp_json.get("data", {})
result_block = data.get("result", {}) if isinstance(data, dict) else {}
status = result_block.get("status") if isinstance(result_block, dict) else None
# Finalise the session
session.get("https://www.netflix.com/browse", timeout=30)
auth_cookies = {cookie.name: cookie.value for cookie in session.cookies}
auth_cookies_path.write_text(json.dumps(auth_cookies, indent=2), encoding="utf-8")
logger.info("Authentication cookies saved")
has_netflix_id = "NetflixId" in auth_cookies
if status == "SUCCESS" or has_netflix_id:
logger.info("LOGIN SUCCESSFUL")
tokens_output_path.write_text(json.dumps(login_resp_json, indent=4), encoding="utf-8")
result = {"status": "SUCCESS", "auth_cookies": auth_cookies}
# print(json.dumps(result, indent=2))
else:
logger.error("LOGIN FAILED — status=%s cookies=%s", status, list(auth_cookies.keys()))
sys.exit(1)
+336
View File
@@ -0,0 +1,336 @@
from __future__ import annotations
import json
import random
import sys
from typing import Any, Dict, Optional
from pathlib import Path
from urllib.parse import quote
from pywidevine import Cdm as WidevineCdm, Device as WidevineDevice
from modules.msl.ios import MSL_IOS
from modules.helpers import (
ensure_output_dir,
get_nfvdid, get_flow_session_cookies,
save_session_cookies,
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_ios(wvd_path: Path,
new_msl: bool = False, no_verify: bool = False,
proxy: Optional[str] = None):
log = setup_logger('IOS MSL')
OUTPUT_DIR = ensure_output_dir("ios")
NETFLIX_HOME_URL = "https://www.netflix.com/"
NETFLIX_CANONICAL_URL = "https://netflix.com/"
GRAPHQL_URL = "https://ios.prod.cloud.netflix.com/graphql"
LOGIN_URL = "https://www.netflix.com/login"
BROWSE_URL = "https://www.netflix.com/browse"
APPBOOT_URL = "https://ios18.appboot.netflix.com/appboot/NFANDROID1-PRV-P-"
MSL_HANDSHAKE_ENDPOINT = "https://ios.prod.ftl.netflix.com/nq/iosplatform/pbo_license/~1.0.0/router"
USER_AGENT = "Netflix/5850 CFNetwork/3826.600.41 Darwin/24.6.0"
CLIENT_VERSION = "18.26.0"
APP_VERSION = "18.26.0"
HAWKINS_VERSION = "5.16.0"
UI_FLAVOR = "argo"
OS_VERSION = "18.6.2"
FORM_FACTOR = "phone"
FEATURE_CAPABILITIES = "supportsStudioBranding"
LOCALE = "en-US"
DEVICE_MODEL = "iPhone15,3"
if not wvd_path.exists():
raise FileNotFoundError(f"Missing WVD file: {wvd_path}")
device = WidevineDevice.load(wvd_path)
cdm = WidevineCdm.from_device(device)
_sid = device.system_id
MSL_CACHE_PATH = OUTPUT_DIR / f"msl_keys_cache_ios_{_sid}.json"
AUTH_COOKIES_PATH = OUTPUT_DIR / f"netflix_auth_cookies_{_sid}.json"
ESN = f"NFANDROID1-PRV-P-IPHONE15=3-{_sid}-{generate_esn_random_suffix(64)}"
log.info("ESN: %s", ESN)
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 = not no_verify
restore_auth_cookies = False
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_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")
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",
proxy=_proxy,
)
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")
try:
save_session_cookies(session, AUTH_COOKIES_PATH, log)
except Exception:
sys.exit(1)
else:
log.error("LOGIN FAILED")
sys.exit(1)
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Optional
from modules.msl.mgk import MSL_MGK, UserAuthentication
from modules.helpers import (
ensure_output_dir,
save_session_cookies,
)
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_mgk(kpekph_path: Optional[str], esnid: str,
new_msl: bool = False, no_verify: bool = False,
proxy: Optional[str] = None):
log = setup_logger('MGK MSL')
OUTPUT_DIR = ensure_output_dir("mgk")
MSL_CACHE_PATH = OUTPUT_DIR / "msl_keys_cache_mgk.json"
AUTH_COOKIES_PATH = OUTPUT_DIR / "netflix_auth_cookies_mgk.json"
DEVICE_TYPE = "NFANDROID2-PRV-NVIDIASHIELDANDROIDTV2019"
# Resolve ESNID: auto-detect file path vs raw string
esnid_path = Path(esnid)
if esnid_path.is_file():
ESN = MSL_MGK.load_esnid_file(esnid_path)
log.info("Loaded ESNID from file: %s", esnid_path)
else:
ESN = esnid
log.info("Using ESNID as raw string")
# Resolve KpeKph: auto-detect file path vs raw string
kpekph_file_path = None
kpekph_raw = None
if kpekph_path:
kpekph_as_path = Path(kpekph_path)
if kpekph_as_path.is_file():
kpekph_file_path = str(kpekph_as_path)
log.info("Loading KpeKph from file: %s", kpekph_as_path)
else:
kpekph_raw = kpekph_path
log.info("Using KpeKph as raw string")
session = setup_session(verify_tls=not no_verify, proxy=proxy)
_proxy = {"http": proxy, "https": proxy} if proxy else None
log.info("Starting MSL MGK handshake with ESN: %s", ESN)
handshake_headers = MSL_MGK.build_request_headers(
request_name="mintCookies",
esn=ESN,
expiry_timeout=12750,
)
msl_client = MSL_MGK.handshake(
session=session,
sender=ESN,
kpekph_path=kpekph_file_path,
kpekph_raw=kpekph_raw,
msl_keys_path=str(MSL_CACHE_PATH),
cookies=None,
headers=handshake_headers,
proxy=_proxy,
new_msl=new_msl,
)
log.info("MGK handshake completed successfully")
user_auth = UserAuthentication.EmailPassword(EMAIL, PASSWORD).__dict__
manifest_endpoint, manifest_params = MSL_MGK.manifest_request_defaults()
manifest_headers = MSL_MGK.build_request_headers(
request_name="licensedManifest",
esn=ESN,
expiry_timeout=12750,
)
log.info("Sending authenticated MSL request with EMAIL_PASSWORD user auth")
try:
header, payload = msl_client.send_message(
endpoint=manifest_endpoint,
params=manifest_params,
application_data={},
userauthdata=user_auth,
headers=manifest_headers,
)
except Exception:
log.exception("MGK authenticated request failed")
sys.exit(1)
try:
auth_cookies = save_session_cookies(session, AUTH_COOKIES_PATH, log)
except Exception:
sys.exit(1)
result = {
"auth_cookies": auth_cookies,
"header": header,
"payload": payload,
}
log.info("MGK login succeeded")
print(json.dumps(result, indent=2, default=str))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+475
View File
@@ -0,0 +1,475 @@
from __future__ import annotations
import json
import random
import re
import sys
import uuid
from pathlib import Path
from typing import Optional
from urllib.parse import quote
from modules.msl.web import MSL_WEB
from modules.helpers import (
ensure_output_dir,
generate_request_id, generate_hex_id,
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_web(new_msl: bool = False, no_verify: bool = False,
recaptcha_token: str = '', proxy: Optional[str] = None):
log = setup_logger('BROWSER MSL')
OUTPUT_DIR = ensure_output_dir("browser")
MSL_CACHE_PATH = OUTPUT_DIR / "msl_keys_cache_web.json"
PRELOGIN_COOKIES_PATH = OUTPUT_DIR / "netflix_prelogin_cookies.json"
AUTH_COOKIES_PATH = OUTPUT_DIR / "netflix_auth_cookies.json"
NETFLIX_HOME_URL = "https://www.netflix.com/"
NETFLIX_CANONICAL_URL = "https://netflix.com/"
GRAPHQL_URL = "https://web.prod.cloud.netflix.com/graphql"
LOGIN_URL = "https://www.netflix.com/login"
BROWSE_URL = "https://www.netflix.com/browse"
MSL_ALE_ENDPOINT = "https://www.netflix.com/nq/msl_v1/nrdjs/pbo_tokens/%5E1.0.0/router"
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/146.0.0.0 Safari/537.36"
)
CLIENT_VERSION = "6.135.459.031"
APP_VERSION = "ve300d66c"
HAWKINS_VERSION = "5.16.0"
UI_FLAVOR = "akira"
MSL_ESN = f"NFCDCH-02-{generate_hex_id(32, uppercase=True)}"
REQUEST_CLIENT_CONTEXT = '{"appstate":"foreground"}'
RECAPTCHA_SITE_KEY = "6Lf8hrcUAAAAAIpQAFW2VFjtiYnThOjZOA5xvLyR"
QUERY_IDS = {
"MembershipStatus": {"id": "3f50f3b3-fff8-48c0-bbd3-5fa2cb04b3c1", "version": 102},
"CLCSScreenUpdate": {"id": "1c276cdf-caef-49cf-b38e-384972c2b47e", "version": 102},
"CLCSSendFeedback": {"id": "079b2271-196b-4edd-b65c-e9439b22e305", "version": 102},
"CLCSInterstitialProfileGate": {"id": "b6e10c7d-0e6f-4921-83b5-177995a80d97", "version": 102},
}
recaptcha_token = ""
verify_tls = not no_verify
restore_prelogin_cookies = True
restore_auth_cookies = False
session = setup_session(verify_tls=verify_tls, proxy=proxy)
_proxy = {"http": proxy, "https": proxy} if proxy else None
if restore_auth_cookies:
MSL_WEB.load_cookiejar(session, AUTH_COOKIES_PATH)
elif restore_prelogin_cookies:
MSL_WEB.load_cookiejar(session, PRELOGIN_COOKIES_PATH)
log.info("Bootstrapping anonymous browser session")
response = session.get(NETFLIX_CANONICAL_URL, timeout=30, allow_redirects=True)
response.raise_for_status()
home_response = session.get(NETFLIX_HOME_URL, timeout=30)
home_response.raise_for_status()
log.info("Sending MembershipStatus probe")
operation_name = "MembershipStatus"
variables = {}
referer = NETFLIX_HOME_URL
originating_url = NETFLIX_HOME_URL
headers = {
"Host": "web.prod.cloud.netflix.com",
"Connection": "keep-alive",
"x-netflix.request.id": generate_request_id(),
"x-netflix.context.operation-name": operation_name,
"x-netflix.request.originating.url": originating_url,
"x-netflix.context.app-version": APP_VERSION,
"x-netflix.context.hawkins-version": HAWKINS_VERSION,
"x-netflix.context.locales": "en-us",
"x-netflix.context.ui-flavor": UI_FLAVOR,
"x-netflix.request.toplevel.uuid": str(uuid.uuid4()),
"x-netflix.request.attempt": "1",
"x-netflix.request.client.context": REQUEST_CLIENT_CONTEXT,
"x-netflix.request.clcs.bucket": "high",
"User-Agent": USER_AGENT,
"Accept": "application/json",
"Content-Type": "application/json",
"Origin": "https://www.netflix.com",
"Referer": referer,
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9",
"x-netflix.request.client.version": CLIENT_VERSION,
"x-netflix.request.client.id": "ui/akiraWeb",
}
body = {
"operationName": operation_name,
"variables": variables,
"extensions": {"persistedQuery": QUERY_IDS[operation_name]},
}
response = session.post(GRAPHQL_URL, json=body, headers=headers, timeout=30)
response.raise_for_status()
membership_status_response = response.json()
if "errors" in membership_status_response:
raise RuntimeError(json.dumps(membership_status_response["errors"], indent=2))
log.info("Fetching login page and extracting screen context")
response = session.get(LOGIN_URL, timeout=30)
response.raise_for_status()
login_html = response.text
clcs_session_id = extract_clcs_session_id(login_html)
rendition_id = extract_rendition_id(login_html)
screen_name = "IDENTIFICATION"
screen_name = "PASSWORD_LOGIN"
log.info("Submitting password step directly to PASSWORD_LOGIN")
from typing import Any, Dict
session_context: Dict[str, Any] = {
"session-breadcrumbs": {"funnel_name": "loginWeb"},
}
session_context.update({
"login.navigationSettings": {"hideOtpToggle": True},
})
full_server_state = {
"realm": "growth",
"name": "PASSWORD_LOGIN",
"clcsSessionId": clcs_session_id,
"sessionContext": session_context,
}
full_screen_update = {
"realm": "custom",
"name": "growthLoginByPassword",
"metadata": {"recaptchaSiteKey": RECAPTCHA_SITE_KEY},
"loggingAction": "Submitted",
"loggingCommand": "SubmitCommand",
"referrerRenditionId": rendition_id,
}
full_variables = {
"format": "HTML",
"imageFormat": "PNG",
"locale": "en-US",
"serverState": json.dumps(full_server_state, separators=(",", ":")),
"serverScreenUpdate": json.dumps(full_screen_update, separators=(",", ":")),
"inputFields": [
{"name": "password", "value": {"stringValue": PASSWORD}},
{"name": "userLoginId", "value": {"stringValue": EMAIL}},
{"name": "countryCode", "value": {"stringValue": "1"}},
{"name": "countryIsoCode", "value": {"stringValue": "US"}},
{"name": "recaptchaResponseTime", "value": {"intValue": 445}},
{"name": "recaptchaResponseToken", "value": {"stringValue": recaptcha_token}},
],
}
try:
operation_name = "CLCSScreenUpdate"
referer = NETFLIX_HOME_URL
originating_url = f"{LOGIN_URL}?serverState={quote(json.dumps(full_server_state, separators=(',', ':')))}"
headers = {
"Host": "web.prod.cloud.netflix.com",
"Connection": "keep-alive",
"x-netflix.request.id": generate_request_id(),
"x-netflix.context.operation-name": operation_name,
"x-netflix.request.originating.url": originating_url,
"x-netflix.context.app-version": APP_VERSION,
"x-netflix.context.hawkins-version": HAWKINS_VERSION,
"x-netflix.context.locales": "en-us",
"x-netflix.context.ui-flavor": UI_FLAVOR,
"x-netflix.request.toplevel.uuid": str(uuid.uuid4()),
"x-netflix.request.attempt": "1",
"x-netflix.request.client.context": REQUEST_CLIENT_CONTEXT,
"x-netflix.request.clcs.bucket": "high",
"User-Agent": USER_AGENT,
"Accept": "application/json",
"Content-Type": "application/json",
"Origin": "https://www.netflix.com",
"Referer": referer,
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9",
"x-netflix.request.client.version": CLIENT_VERSION,
"x-netflix.request.client.id": "ui/akiraWeb",
}
body = {
"operationName": operation_name,
"variables": full_variables,
"extensions": {"persistedQuery": QUERY_IDS[operation_name]},
}
response = session.post(GRAPHQL_URL, json=body, headers=headers, timeout=30)
response.raise_for_status()
login_response = response.json()
if "errors" in login_response:
raise RuntimeError(json.dumps(login_response["errors"], indent=2))
except Exception as exc:
log.warning("Full PASSWORD_LOGIN submit failed, retrying with minimal payload: %s", exc)
session_context = {
"session-breadcrumbs": {"funnel_name": "loginWeb"},
}
minimal_server_state = {
"realm": "growth",
"name": "PASSWORD_LOGIN",
"clcsSessionId": clcs_session_id,
"sessionContext": session_context,
}
minimal_screen_update = {
"realm": "custom",
"name": "growthLoginByPassword",
}
minimal_variables = {
"format": "HTML",
"imageFormat": "PNG",
"locale": "en-US",
"serverState": json.dumps(minimal_server_state, separators=(",", ":")),
"serverScreenUpdate": json.dumps(minimal_screen_update, separators=(",", ":")),
"inputFields": [
{"name": "userLoginId", "value": {"stringValue": EMAIL}},
{"name": "password", "value": {"stringValue": PASSWORD}},
],
}
operation_name = "CLCSScreenUpdate"
referer = NETFLIX_HOME_URL
originating_url = f"{LOGIN_URL}?serverState={quote(json.dumps(minimal_server_state, separators=(',', ':')))}"
headers = {
"Host": "web.prod.cloud.netflix.com",
"Connection": "keep-alive",
"x-netflix.request.id": generate_request_id(),
"x-netflix.context.operation-name": operation_name,
"x-netflix.request.originating.url": originating_url,
"x-netflix.context.app-version": APP_VERSION,
"x-netflix.context.hawkins-version": HAWKINS_VERSION,
"x-netflix.context.locales": "en-us",
"x-netflix.context.ui-flavor": UI_FLAVOR,
"x-netflix.request.toplevel.uuid": str(uuid.uuid4()),
"x-netflix.request.attempt": "1",
"x-netflix.request.client.context": REQUEST_CLIENT_CONTEXT,
"x-netflix.request.clcs.bucket": "high",
"User-Agent": USER_AGENT,
"Accept": "application/json",
"Content-Type": "application/json",
"Origin": "https://www.netflix.com",
"Referer": referer,
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9",
"x-netflix.request.client.version": CLIENT_VERSION,
"x-netflix.request.client.id": "ui/akiraWeb",
}
body = {
"operationName": operation_name,
"variables": minimal_variables,
"extensions": {"persistedQuery": QUERY_IDS[operation_name]},
}
response = session.post(GRAPHQL_URL, json=body, headers=headers, timeout=30)
response.raise_for_status()
login_response = response.json()
if "errors" in login_response:
raise RuntimeError(json.dumps(login_response["errors"], indent=2))
response_text = json.dumps(login_response, ensure_ascii=False)
screen_names = re.findall(r'"name":"([A-Z_]+)"', response_text)
rendition_ids = re.findall(r'"renditionId":"([0-9a-f\-]{36})"', response_text)
clcs_match = re.search(r'"clcsSessionId":"([0-9a-f\-]{36})"', response_text)
next_clcs_session_id = clcs_match.group(1) if clcs_match else clcs_session_id
next_screen_name = screen_names[-1] if screen_names else "PASSWORD_LOGIN"
next_rendition_id = rendition_ids[-1] if rendition_ids else rendition_id
feedback_payload = None
effect = login_response.get("data", {}).get("result", {}).get("effect", {})
nodes = effect.get("nodes", []) if isinstance(effect, dict) else []
for node in nodes:
if node.get("__typename") == "CLCSSendFeedback" and node.get("serverFeedback"):
feedback_payload = json.loads(node["serverFeedback"])
break
if feedback_payload:
log.info("Sending CLCSSendFeedback after successful login")
session_context = {
"session-breadcrumbs": {"funnel_name": "loginWeb"},
}
session_context.update({
"login.navigationSettings": {"hideOtpToggle": True},
})
feedback_server_state = {
"realm": "growth",
"name": "PASSWORD_LOGIN",
"clcsSessionId": next_clcs_session_id,
"sessionContext": session_context,
}
operation_name = "CLCSSendFeedback"
feedback_variables = {
"inputFields": [],
"serverFeedback": json.dumps(feedback_payload, separators=(",", ":")),
"serverState": json.dumps(feedback_server_state, separators=(",", ":")),
}
referer = NETFLIX_HOME_URL
originating_url = f"{LOGIN_URL}?serverState={quote(json.dumps(feedback_server_state, separators=(',', ':')))}"
headers = {
"Host": "web.prod.cloud.netflix.com",
"Connection": "keep-alive",
"x-netflix.request.id": generate_request_id(),
"x-netflix.context.operation-name": operation_name,
"x-netflix.request.originating.url": originating_url,
"x-netflix.context.app-version": APP_VERSION,
"x-netflix.context.hawkins-version": HAWKINS_VERSION,
"x-netflix.context.locales": "en-us",
"x-netflix.context.ui-flavor": UI_FLAVOR,
"x-netflix.request.toplevel.uuid": str(uuid.uuid4()),
"x-netflix.request.attempt": "1",
"x-netflix.request.client.context": REQUEST_CLIENT_CONTEXT,
"x-netflix.request.clcs.bucket": "high",
"User-Agent": USER_AGENT,
"Accept": "application/json",
"Content-Type": "application/json",
"Origin": "https://www.netflix.com",
"Referer": referer,
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9",
"x-netflix.request.client.version": CLIENT_VERSION,
"x-netflix.request.client.id": "ui/akiraWeb",
}
body = {
"operationName": operation_name,
"variables": feedback_variables,
"extensions": {"persistedQuery": QUERY_IDS[operation_name]},
}
response = session.post(GRAPHQL_URL, json=body, headers=headers, timeout=30)
response.raise_for_status()
feedback_response = response.json()
if "errors" in feedback_response:
raise RuntimeError(json.dumps(feedback_response["errors"], indent=2))
else:
log.info("No post-login feedback payload was found")
log.info("Opening /browse to finalize the authenticated web session")
session_context = {
"session-breadcrumbs": {"funnel_name": "loginWeb"},
}
session_context.update({
"login.navigationSettings": {"hideOtpToggle": True},
})
browse_server_state = {
"realm": "growth",
"name": "PASSWORD_LOGIN",
"clcsSessionId": next_clcs_session_id,
"sessionContext": session_context,
}
browse_originating_url = f"{LOGIN_URL}?serverState={quote(json.dumps(browse_server_state, separators=(',', ':')))}"
response = session.get(
BROWSE_URL,
headers={
"Referer": browse_originating_url,
"Accept-Language": "en-US,en;q=0.9",
},
timeout=30,
)
response.raise_for_status()
log.info("Probing the post-login profile gate")
profile_gate_response = None
try:
operation_name = "CLCSInterstitialProfileGate"
variables = {"format": "HTML", "resolutionMode": "WEB_1X"}
referer = NETFLIX_HOME_URL
originating_url = BROWSE_URL
headers = {
"Host": "web.prod.cloud.netflix.com",
"Connection": "keep-alive",
"x-netflix.request.id": generate_request_id(),
"x-netflix.context.operation-name": operation_name,
"x-netflix.request.originating.url": originating_url,
"x-netflix.context.app-version": APP_VERSION,
"x-netflix.context.hawkins-version": HAWKINS_VERSION,
"x-netflix.context.locales": "en-us",
"x-netflix.context.ui-flavor": UI_FLAVOR,
"x-netflix.request.toplevel.uuid": str(uuid.uuid4()),
"x-netflix.request.attempt": "1",
"x-netflix.request.client.context": REQUEST_CLIENT_CONTEXT,
"x-netflix.request.clcs.bucket": "high",
"User-Agent": USER_AGENT,
"Accept": "application/json",
"Content-Type": "application/json",
"Origin": "https://www.netflix.com",
"Referer": referer,
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9",
"x-netflix.request.client.version": CLIENT_VERSION,
"x-netflix.request.client.id": "ui/akiraWeb",
}
body = {
"operationName": operation_name,
"variables": variables,
"extensions": {"persistedQuery": QUERY_IDS[operation_name]},
}
response = session.post(GRAPHQL_URL, json=body, headers=headers, timeout=30)
response.raise_for_status()
profile_gate_response = response.json()
if "errors" in profile_gate_response:
raise RuntimeError(json.dumps(profile_gate_response["errors"], indent=2))
except Exception as exc:
log.warning("Profile gate probe failed: %s", exc)
log.info("Attempting post-login ALE provision")
ale_response = None
try:
final_cookies = MSL_WEB.cookiejar_to_ordered_dict(session.cookies)
req_id = generate_request_id()
endpoint = (
f"{MSL_ALE_ENDPOINT}?reqAttempt=1&reqName=aleProvision&reqId={req_id}"
f"&clienttype={UI_FLAVOR}&uiversion={APP_VERSION}&browsername=chrome"
f"&browserversion=146.0.0.0&osname=windows&osversion=10.0"
)
headers = MSL_WEB.build_request_headers(
request_name="aleProvision",
user_agent=USER_AGENT,
referer=BROWSE_URL,
esn=MSL_ESN,
extra_headers={
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9",
},
)
ale_response = MSL_WEB.handshake(
msl_keys_path=MSL_CACHE_PATH,
session=session,
sender=MSL_ESN,
new_msl=False,
cookies=final_cookies,
endpoint=endpoint,
headers=headers,
)
except Exception as exc:
log.error("ALE provision failed: %s", exc)
auth_cookies = MSL_WEB.cookiejar_to_ordered_dict(session.cookies)
AUTH_COOKIES_PATH.write_text(json.dumps(auth_cookies, indent=2), encoding="utf-8")
result = {
"auth_cookies": auth_cookies,
}
#print(json.dumps(result, indent=2))
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import logging
import ssl
import certifi
_log = logging.getLogger(__name__)
import requests
import urllib3
from requests.adapters import HTTPAdapter
from typing import Any, Optional
# On Windows, pip._vendor.truststore monkey-patches ssl.SSLContext.wrap_socket to add
# a Windows trust store check AFTER Python's own TLS verification. Netflix's root CAs
# are in certifi but not always in the Windows cert store, so the Windows check fails.
# Patch _verify_peercerts (the truststore post-handshake hook) to a no-op so that
# only Python's built-in verification against the certifi CA bundle is used.
try:
import pip._vendor.truststore._api as _ts_api
_ts_api._verify_peercerts = lambda ssl_sock, server_hostname=None: None
except Exception:
pass
class CertifiAdapter(HTTPAdapter):
def __init__(self, ssl_context: Optional[ssl.SSLContext] = None, *args: Any, **kwargs: Any) -> 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