mirror of
https://github.com/Ap0dexMe0/NF-MSL.git
synced 2026-09-24 01:52:02 +02:00
refactored
This commit is contained in:
+60
-26
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
@@ -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 -----------------------------------
|
||||
@@ -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 -----------------------------------
|
||||
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user