Files
NF-MSL/modules/msl/web.py
T
2026-05-30 10:52:45 +08:00

413 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
from typing import Any, Dict, Iterable, List, Optional, Tuple
import jsonpickle
import requests
from Cryptodome.Cipher import PKCS1_OAEP
from Cryptodome.PublicKey import RSA
from Cryptodome.PublicKey.RSA import RsaKey
from .base import MSLBase, MSLKeys as _BaseMSLKeys
# ---------------------------------------------------------------------------
# Platform-specific key container
# ---------------------------------------------------------------------------
class MSLKeys(_BaseMSLKeys):
"""Web MSL keys extends the base with an RSA key (no CDM session)."""
def __init__(
self,
encryption: Optional[bytes] = None,
sign: Optional[bytes] = None,
rsa: Optional[RsaKey] = None,
mastertoken: Optional[dict] = None,
) -> None:
super().__init__(encryption=encryption, sign=sign, mastertoken=mastertoken)
self.rsa = rsa
# ---------------------------------------------------------------------------
# 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 = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/146.0.0.0 Safari/537.36"
)
DEFAULT_REQUEST_CONTEXT: str = '{"appstate":"foreground"}'
DEFAULT_NRDJS_VERSION: str = "v3.11.512"
DEFAULT_NETJS_VERSION: str = "3.0.5"
# -- Constructor ---------------------------------------------------------
def __init__(
self,
session: requests.Session,
keys: MSLKeys,
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, proxy=proxy)
self.user_auth = user_auth
# -- RSA handshake -------------------------------------------------------
@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:
"""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",
"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=(",", ":"),
)
_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]}"
)
parsed = cls.parse_concatenated_json(response.text)
if not parsed:
raise RuntimeError("Key exchange failed: empty MSL response")
header = parsed[0]
if "errordata" in header:
decoded_error = base64.standard_b64decode(header["errordata"]).decode(
"utf-8"
)
raise RuntimeError(f"Key exchange failed: {decoded_error}")
if "headerdata" not in header:
raise RuntimeError(
f"Key exchange failed: missing headerdata: {str(header)[:500]}"
)
header_json = json.loads(
base64.standard_b64decode(header["headerdata"]).decode("utf-8")
)
key_data = header_json["keyresponsedata"]["keydata"]
cipher_rsa = PKCS1_OAEP.new(keys.rsa)
keys.encryption = cls.base64key_decode(
json.loads(
cipher_rsa.decrypt(
base64.standard_b64decode(key_data["encryptionkey"])
).decode("utf-8")
)["k"]
)
keys.sign = cls.base64key_decode(
json.loads(
cipher_rsa.decrypt(
base64.standard_b64decode(key_data["hmackey"])
).decode("utf-8")
)["k"]
)
keys.mastertoken = header_json["keyresponsedata"]["mastertoken"]
cls.cache_keys(keys, cache_path)
_log.info("Web RSA key exchange complete")
return keys
# -- Platform-specific request headers -----------------------------------
@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]:
"""Build the HTTP headers required for Web MSL requests."""
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
# -- Custom generate_msg_header (empty languages / encoderformats) -------
@staticmethod
def generate_msg_header(
message_id: int,
sender: str,
is_handshake: bool,
userauthdata: Optional[dict] = None,
keyrequestdata: Optional[dict] = None,
compression: Optional[str] = "GZIP",
) -> str:
"""Generate an MSL message header with Web-specific capabilities.
The Web platform sends empty ``languages`` and ``encoderformats``
arrays in the capabilities block.
"""
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)
# -- Web-specific send_message (no PBO normalisation) --------------------
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]:
"""Send an MSL message without PBO payload normalisation.
Raises :class:`RuntimeError` on MSL errors.
"""
message = self.create_message(application_data, userauthdata)
response = self.session.post(
url=endpoint, params=params, data=message, headers=headers, timeout=30
)
response.raise_for_status()
header, payload = self.parse_message(response.text)
if "errordata" in header:
decoded_error = json.loads(
base64.standard_b64decode(header["errordata"]).decode("utf-8")
)
raise RuntimeError(f"MSL response contains an error: {decoded_error}")
return header, payload
# -- Cache I/O overrides (RSA key serialisation) -------------------------
@staticmethod
def load_cache_data(msl_keys_path: Optional[Path] = None) -> Optional[MSLKeys]:
"""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:
return None
# Re-import RSA key from serialised PEM form
if getattr(msl_keys, "rsa", None):
msl_keys.rsa = RSA.import_key(msl_keys.rsa)
return msl_keys
@staticmethod
def cache_keys(msl_keys: MSLKeys, msl_keys_path: Path) -> None:
"""Persist keys, exporting the RSA key to PEM for JSON compatibility."""
original_rsa = msl_keys.rsa
if msl_keys.rsa:
msl_keys.rsa = msl_keys.rsa.export_key()
MSLBase.cache_keys(msl_keys, msl_keys_path)
if original_rsa:
msl_keys.rsa = original_rsa
# -- Cookie-jar helpers (Web-specific) -----------------------------------
@staticmethod
def cookiejar_to_list(cookie_jar: CookieJar) -> List[Dict[str, Any]]:
"""Serialise a :class:`CookieJar` to a list of dicts."""
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]:
"""Convert a :class:`CookieJar` to an :class:`OrderedDict` of name→value."""
cookies: Dict[str, str] = OrderedDict()
for cookie in cookie_jar:
cookies[cookie.name] = cookie.value
return cookies
@staticmethod
def save_cookie_values(
cookie_jar: CookieJar, output_path: str | Path
) -> Dict[str, str]:
"""Save cookie name→value pairs to a JSON file and return them."""
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:
"""Save the full cookie jar to a JSON file."""
path = Path(output_path)
path.write_text(
json.dumps(MSL_WEB.cookiejar_to_list(cookie_jar), indent=2),
encoding="utf-8",
)
@staticmethod
def load_cookiejar(
session: requests.Session,
source: str | Path | Iterable[Dict[str, Any]] | Dict[str, str],
) -> None:
"""Load cookies into *session* from a file path, iterable, or dict."""
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", {}),
)
__all__ = ["MSL_WEB", "MSLKeys"]