mirror of
https://github.com/Ap0dexMe0/NF-MSL.git
synced 2026-09-16 14:12:06 +02:00
413 lines
15 KiB
Python
413 lines
15 KiB
Python
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"]
|