Files
NF-MSL/modules/msl/web.py
T

413 lines
15 KiB
Python
Raw Normal View History

2026-05-29 17:53:25 +08:00
from __future__ import annotations
2026-05-29 20:53:59 +08:00
2026-05-29 17:53:25 +08:00
import base64
import json
2026-05-30 10:52:45 +08:00
import logging
2026-05-29 17:53:25 +08:00
import random
2026-05-29 20:53:59 +08:00
from collections import OrderedDict
2026-05-30 10:52:45 +08:00
_log = logging.getLogger(__name__)
2026-05-29 17:53:25 +08:00
from datetime import datetime, timezone
from http.cookiejar import CookieJar
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
2026-05-29 20:53:59 +08:00
import jsonpickle
import requests
from Cryptodome.Cipher import PKCS1_OAEP
2026-05-29 17:53:25 +08:00
from Cryptodome.PublicKey import RSA
from Cryptodome.PublicKey.RSA import RsaKey
2026-05-29 20:53:59 +08:00
2026-05-30 10:52:45 +08:00
from .base import MSLBase, MSLKeys as _BaseMSLKeys
2026-05-29 17:53:25 +08:00
2026-05-29 20:53:59 +08:00
# ---------------------------------------------------------------------------
# Platform-specific key container
# ---------------------------------------------------------------------------
2026-05-29 17:53:25 +08:00
2026-05-29 20:53:59 +08:00
class MSLKeys(_BaseMSLKeys):
"""Web MSL keys – extends the base with an RSA key (no CDM session)."""
2026-05-29 17:53:25 +08:00
def __init__(
self,
encryption: Optional[bytes] = None,
sign: Optional[bytes] = None,
rsa: Optional[RsaKey] = None,
mastertoken: Optional[dict] = None,
2026-05-29 20:53:59 +08:00
) -> None:
super().__init__(encryption=encryption, sign=sign, mastertoken=mastertoken)
2026-05-29 17:53:25 +08:00
self.rsa = rsa
2026-05-29 20:53:59 +08:00
# ---------------------------------------------------------------------------
# MSL_WEB
# ---------------------------------------------------------------------------
class MSL_WEB(MSLBase):
"""Netflix MSL client for web browsers using RSA key exchange."""
# -- Platform constants --------------------------------------------------
DEFAULT_HANDSHAKE_ENDPOINT: str = (
"https://www.netflix.com/nq/msl_v1/nrdjs/pbo_tokens/%5E1.0.0/router"
)
DEFAULT_USER_AGENT: str = (
2026-05-29 17:53:25 +08:00
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/146.0.0.0 Safari/537.36"
)
2026-05-29 20:53:59 +08:00
DEFAULT_REQUEST_CONTEXT: str = '{"appstate":"foreground"}'
DEFAULT_NRDJS_VERSION: str = "v3.11.512"
DEFAULT_NETJS_VERSION: str = "3.0.5"
# -- Constructor ---------------------------------------------------------
2026-05-29 17:53:25 +08:00
def __init__(
self,
session: requests.Session,
keys: MSLKeys,
message_id: int,
sender: str,
user_auth: Optional[dict] = None,
2026-05-30 10:52:45 +08:00
proxy: Optional[Dict[str, str]] = None,
2026-05-29 20:53:59 +08:00
) -> None:
2026-05-30 10:52:45 +08:00
super().__init__(session=session, keys=keys, message_id=message_id, sender=sender, proxy=proxy)
2026-05-29 17:53:25 +08:00
self.user_auth = user_auth
2026-05-29 20:53:59 +08:00
# -- RSA handshake -------------------------------------------------------
2026-05-29 17:53:25 +08:00
@classmethod
def handshake(
cls,
msl_keys_path: str | Path,
session: requests.Session,
sender: str,
new_msl: bool = False,
cookies: Optional[Dict[str, str]] = None,
endpoint: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
) -> MSLKeys:
2026-05-29 20:53:59 +08:00
"""Perform an RSA (ASYMMETRIC_WRAPPED) key exchange."""
2026-05-30 10:52:45 +08:00
_log.info("Web RSA handshake: sender=%s", sender)
2026-05-29 17:53:25 +08:00
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:
2026-05-30 10:52:45 +08:00
_log.info("Reusing cached MSL keys")
2026-05-29 17:53:25 +08:00
return cached
2026-05-30 10:52:45 +08:00
_log.info("Performing fresh RSA key exchange")
2026-05-29 17:53:25 +08:00
message_id = random.randint(0, 2**52)
keys = MSLKeys()
keys.rsa = RSA.generate(2048)
2026-05-30 10:52:45 +08:00
_log.debug("Generated RSA-2048 ephemeral keypair")
2026-05-29 17:53:25 +08:00
keyrequestdata = {
"scheme": "ASYMMETRIC_WRAPPED",
"keydata": {
"keypairid": "rsaKeypairId",
"mechanism": "JWK_RSA",
"publickey": base64.b64encode(
keys.rsa.publickey().export_key(format="DER")
).decode("utf-8"),
},
}
envelope = jsonpickle.encode(
{
"entityauthdata": {
"scheme": "NONE",
"authdata": {"identity": sender},
},
"headerdata": base64.standard_b64encode(
cls.generate_msg_header(
message_id=message_id,
sender=sender,
is_handshake=True,
keyrequestdata=keyrequestdata,
).encode("utf-8")
).decode("utf-8"),
"signature": "",
},
unpicklable=False,
)
envelope += json.dumps(
{
"payload": base64.standard_b64encode(
json.dumps(
{
"messageid": message_id,
"data": "",
"sequencenumber": 1,
"endofmsg": True,
}
).encode("utf-8")
).decode("utf-8"),
"signature": "",
},
separators=(",", ":"),
)
2026-05-30 10:52:45 +08:00
_log.debug("Web handshake request → %s", endpoint or cls.DEFAULT_HANDSHAKE_ENDPOINT)
2026-05-29 17:53:25 +08:00
response = session.post(
url=endpoint or cls.DEFAULT_HANDSHAKE_ENDPOINT,
data=envelope,
headers=headers or cls.build_request_headers(request_name="aleProvision"),
timeout=30,
)
2026-05-30 10:52:45 +08:00
_log.debug("Web handshake response ← HTTP %d", response.status_code)
2026-05-29 17:53:25 +08:00
if response.status_code != 200:
2026-05-29 20:53:59 +08:00
raise RuntimeError(
f"Key exchange failed: HTTP {response.status_code} {response.text[:500]}"
)
2026-05-29 17:53:25 +08:00
parsed = cls.parse_concatenated_json(response.text)
if not parsed:
raise RuntimeError("Key exchange failed: empty MSL response")
header = parsed[0]
if "errordata" in header:
2026-05-29 20:53:59 +08:00
decoded_error = base64.standard_b64decode(header["errordata"]).decode(
"utf-8"
)
2026-05-29 17:53:25 +08:00
raise RuntimeError(f"Key exchange failed: {decoded_error}")
if "headerdata" not in header:
2026-05-29 20:53:59 +08:00
raise RuntimeError(
f"Key exchange failed: missing headerdata: {str(header)[:500]}"
)
2026-05-29 17:53:25 +08:00
2026-05-29 20:53:59 +08:00
header_json = json.loads(
base64.standard_b64decode(header["headerdata"]).decode("utf-8")
)
2026-05-29 17:53:25 +08:00
key_data = header_json["keyresponsedata"]["keydata"]
cipher_rsa = PKCS1_OAEP.new(keys.rsa)
keys.encryption = cls.base64key_decode(
2026-05-29 20:53:59 +08:00
json.loads(
cipher_rsa.decrypt(
base64.standard_b64decode(key_data["encryptionkey"])
).decode("utf-8")
)["k"]
2026-05-29 17:53:25 +08:00
)
keys.sign = cls.base64key_decode(
2026-05-29 20:53:59 +08:00
json.loads(
cipher_rsa.decrypt(
base64.standard_b64decode(key_data["hmackey"])
).decode("utf-8")
)["k"]
2026-05-29 17:53:25 +08:00
)
keys.mastertoken = header_json["keyresponsedata"]["mastertoken"]
cls.cache_keys(keys, cache_path)
2026-05-30 10:52:45 +08:00
_log.info("Web RSA key exchange complete")
2026-05-29 17:53:25 +08:00
return keys
2026-05-29 20:53:59 +08:00
# -- Platform-specific request headers -----------------------------------
2026-05-29 17:53:25 +08:00
@staticmethod
def build_request_headers(
request_name: str,
user_agent: Optional[str] = None,
referer: Optional[str] = None,
viewable_id: Optional[int] = None,
profile_guid: Optional[str] = None,
esn: Optional[str] = None,
expiry_timeout: Optional[int] = 12750,
extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, str]:
2026-05-29 20:53:59 +08:00
"""Build the HTTP headers required for Web MSL requests."""
2026-05-29 17:53:25 +08:00
headers: Dict[str, str] = {
"User-Agent": user_agent or MSL_WEB.DEFAULT_USER_AGENT,
"Accept": "*/*",
"Content-Type": "application/json",
"Content-Encoding": "msl_v1",
"Origin": "https://www.netflix.com",
"X-Netflix.Client.Request.Name": request_name,
"X-Netflix.request.attempt": "1",
"X-Netflix.Request.NonJson.Headers": "true",
"X-Netflix.Request.Client.Context": MSL_WEB.DEFAULT_REQUEST_CONTEXT,
"x-netflix.client.nrdjs.version": MSL_WEB.DEFAULT_NRDJS_VERSION,
"x-netflix.client.netjs.version": MSL_WEB.DEFAULT_NETJS_VERSION,
"x-netflix.client.last-interacted-days": "0",
}
if expiry_timeout is not None:
headers["x-netflix.request.expiry.timeout"] = str(expiry_timeout)
if referer:
headers["Referer"] = referer
if viewable_id is not None:
headers["x-netflix.playback.main-content-viewable-id"] = str(viewable_id)
if profile_guid:
headers["x-netflix.client.current-profile-guid"] = profile_guid
if esn:
headers["x-netflix.client.ftl.esn"] = esn
if extra_headers:
headers.update(extra_headers)
return headers
2026-05-29 20:53:59 +08:00
# -- Custom generate_msg_header (empty languages / encoderformats) -------
2026-05-29 17:53:25 +08:00
@staticmethod
def generate_msg_header(
message_id: int,
sender: str,
is_handshake: bool,
userauthdata: Optional[dict] = None,
keyrequestdata: Optional[dict] = None,
compression: Optional[str] = "GZIP",
) -> str:
2026-05-29 20:53:59 +08:00
"""Generate an MSL message header with Web-specific capabilities.
The Web platform sends empty ``languages`` and ``encoderformats``
arrays in the capabilities block.
"""
2026-05-29 17:53:25 +08:00
header_data: Dict[str, Any] = {
"messageid": message_id,
"renewable": True,
"handshake": is_handshake,
"capabilities": {
"compressionalgos": [compression] if compression else [],
"languages": [],
"encoderformats": [],
},
"timestamp": int(datetime.now(timezone.utc).timestamp()),
"sender": sender,
"nonreplayable": False,
"recipient": "Netflix",
}
if userauthdata:
header_data["userauthdata"] = userauthdata
if keyrequestdata:
header_data["keyrequestdata"] = [keyrequestdata]
return jsonpickle.encode(header_data, unpicklable=False)
2026-05-29 20:53:59 +08:00
# -- Web-specific send_message (no PBO normalisation) --------------------
2026-05-29 17:53:25 +08:00
def send_message(
self,
endpoint: str,
params: Dict[str, str],
application_data: Any,
userauthdata: Optional[dict] = None,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[Dict[str, Any], Any]:
2026-05-29 20:53:59 +08:00
"""Send an MSL message without PBO payload normalisation.
Raises :class:`RuntimeError` on MSL errors.
"""
2026-05-29 17:53:25 +08:00
message = self.create_message(application_data, userauthdata)
2026-05-29 20:53:59 +08:00
response = self.session.post(
url=endpoint, params=params, data=message, headers=headers, timeout=30
)
2026-05-29 17:53:25 +08:00
response.raise_for_status()
header, payload = self.parse_message(response.text)
if "errordata" in header:
2026-05-29 20:53:59 +08:00
decoded_error = json.loads(
base64.standard_b64decode(header["errordata"]).decode("utf-8")
2026-05-29 17:53:25 +08:00
)
2026-05-29 20:53:59 +08:00
raise RuntimeError(f"MSL response contains an error: {decoded_error}")
2026-05-29 17:53:25 +08:00
return header, payload
2026-05-29 20:53:59 +08:00
# -- Cache I/O overrides (RSA key serialisation) -------------------------
2026-05-29 17:53:25 +08:00
@staticmethod
def load_cache_data(msl_keys_path: Optional[Path] = None) -> Optional[MSLKeys]:
2026-05-29 20:53:59 +08:00
"""Load cached keys, re-importing the RSA key from its PEM form."""
msl_keys = MSLBase.load_cache_data(msl_keys_path)
if msl_keys is None:
2026-05-29 17:53:25 +08:00
return None
2026-05-29 20:53:59 +08:00
# Re-import RSA key from serialised PEM form
if getattr(msl_keys, "rsa", None):
2026-05-29 17:53:25 +08:00
msl_keys.rsa = RSA.import_key(msl_keys.rsa)
return msl_keys
@staticmethod
def cache_keys(msl_keys: MSLKeys, msl_keys_path: Path) -> None:
2026-05-29 20:53:59 +08:00
"""Persist keys, exporting the RSA key to PEM for JSON compatibility."""
2026-05-29 17:53:25 +08:00
original_rsa = msl_keys.rsa
if msl_keys.rsa:
msl_keys.rsa = msl_keys.rsa.export_key()
2026-05-29 20:53:59 +08:00
MSLBase.cache_keys(msl_keys, msl_keys_path)
2026-05-29 17:53:25 +08:00
if original_rsa:
msl_keys.rsa = original_rsa
2026-05-29 20:53:59 +08:00
# -- Cookie-jar helpers (Web-specific) -----------------------------------
2026-05-29 17:53:25 +08:00
@staticmethod
def cookiejar_to_list(cookie_jar: CookieJar) -> List[Dict[str, Any]]:
2026-05-29 20:53:59 +08:00
"""Serialise a :class:`CookieJar` to a list of dicts."""
2026-05-29 17:53:25 +08:00
cookies: List[Dict[str, Any]] = []
for cookie in cookie_jar:
cookies.append(
{
"name": cookie.name,
"value": cookie.value,
"domain": cookie.domain,
"path": cookie.path,
"secure": bool(cookie.secure),
"expires": cookie.expires,
"discard": bool(cookie.discard),
"rest": dict(cookie._rest),
}
)
return cookies
@staticmethod
def cookiejar_to_ordered_dict(cookie_jar: CookieJar) -> Dict[str, str]:
2026-05-29 20:53:59 +08:00
"""Convert a :class:`CookieJar` to an :class:`OrderedDict` of name→value."""
2026-05-29 17:53:25 +08:00
cookies: Dict[str, str] = OrderedDict()
for cookie in cookie_jar:
cookies[cookie.name] = cookie.value
return cookies
@staticmethod
2026-05-29 20:53:59 +08:00
def save_cookie_values(
cookie_jar: CookieJar, output_path: str | Path
) -> Dict[str, str]:
"""Save cookie name→value pairs to a JSON file and return them."""
2026-05-29 17:53:25 +08:00
path = Path(output_path)
payload = MSL_WEB.cookiejar_to_ordered_dict(cookie_jar)
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return payload
@staticmethod
def save_cookiejar(cookie_jar: CookieJar, output_path: str | Path) -> None:
2026-05-29 20:53:59 +08:00
"""Save the full cookie jar to a JSON file."""
2026-05-29 17:53:25 +08:00
path = Path(output_path)
2026-05-29 20:53:59 +08:00
path.write_text(
json.dumps(MSL_WEB.cookiejar_to_list(cookie_jar), indent=2),
encoding="utf-8",
)
2026-05-29 17:53:25 +08:00
@staticmethod
2026-05-29 20:53:59 +08:00
def load_cookiejar(
session: requests.Session,
source: str | Path | Iterable[Dict[str, Any]] | Dict[str, str],
) -> None:
"""Load cookies into *session* from a file path, iterable, or dict."""
2026-05-29 17:53:25 +08:00
if isinstance(source, (str, Path)):
path = Path(source)
if not path.is_file():
return
payload = json.loads(path.read_text(encoding="utf-8"))
else:
payload = source
if isinstance(payload, dict):
session.cookies.update(payload)
return
for item in payload:
session.cookies.set(
name=item["name"],
value=item["value"],
domain=item.get("domain"),
path=item.get("path", "/"),
secure=item.get("secure", False),
expires=item.get("expires"),
rest=item.get("rest", {}),
2026-05-29 20:53:59 +08:00
)
__all__ = ["MSL_WEB", "MSLKeys"]