mirror of
https://github.com/Ap0dexMe0/NF-MSL.git
synced 2026-09-16 22:22:07 +02:00
476 lines
18 KiB
Python
476 lines
18 KiB
Python
from __future__ import annotations
|
|
import base64
|
|
import gzip
|
|
import json
|
|
import random
|
|
import zlib
|
|
import jsonpickle
|
|
import requests
|
|
from datetime import datetime, timezone
|
|
from http.cookiejar import CookieJar
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
from collections import OrderedDict
|
|
from Cryptodome.Cipher import AES, PKCS1_OAEP
|
|
from Cryptodome.Hash import HMAC, SHA256
|
|
from Cryptodome.PublicKey import RSA
|
|
from Cryptodome.PublicKey.RSA import RsaKey
|
|
from Cryptodome.Random import get_random_bytes
|
|
from Cryptodome.Util import Padding
|
|
|
|
|
|
class MSLObject:
|
|
def __repr__(self) -> str:
|
|
return f"<{self.__class__.__name__} {jsonpickle.encode(self, unpicklable=False)}>"
|
|
|
|
|
|
class MSLKeys(MSLObject):
|
|
def __init__(
|
|
self,
|
|
encryption: Optional[bytes] = None,
|
|
sign: Optional[bytes] = None,
|
|
rsa: Optional[RsaKey] = None,
|
|
mastertoken: Optional[dict] = None,
|
|
):
|
|
self.encryption = encryption
|
|
self.sign = sign
|
|
self.rsa = rsa
|
|
self.mastertoken = mastertoken
|
|
|
|
|
|
class MSL_WEB:
|
|
DEFAULT_HANDSHAKE_ENDPOINT = "https://www.netflix.com/nq/msl_v1/nrdjs/pbo_tokens/%5E1.0.0/router"
|
|
DEFAULT_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"
|
|
)
|
|
DEFAULT_REQUEST_CONTEXT = '{"appstate":"foreground"}'
|
|
DEFAULT_NRDJS_VERSION = "v3.11.512"
|
|
DEFAULT_NETJS_VERSION = "3.0.5"
|
|
|
|
def __init__(
|
|
self,
|
|
session: requests.Session,
|
|
keys: MSLKeys,
|
|
message_id: int,
|
|
sender: str,
|
|
user_auth: Optional[dict] = None,
|
|
):
|
|
self.session = session
|
|
self.keys = keys
|
|
self.sender = sender
|
|
self.user_auth = user_auth
|
|
self.message_id = message_id
|
|
|
|
@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:
|
|
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:
|
|
return cached
|
|
|
|
message_id = random.randint(0, 2**52)
|
|
keys = MSLKeys()
|
|
keys.rsa = RSA.generate(2048)
|
|
|
|
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=(",", ":"),
|
|
)
|
|
|
|
response = session.post(
|
|
url=endpoint or cls.DEFAULT_HANDSHAKE_ENDPOINT,
|
|
data=envelope,
|
|
headers=headers or cls.build_request_headers(request_name="aleProvision"),
|
|
timeout=30,
|
|
)
|
|
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)
|
|
return keys
|
|
|
|
@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]:
|
|
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
|
|
|
|
@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:
|
|
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)
|
|
|
|
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]:
|
|
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
|
|
|
|
def create_message(self, application_data: Any, userauthdata: Optional[dict] = None) -> str:
|
|
self.message_id += 1
|
|
headerdata = self.encrypt(
|
|
self.generate_msg_header(
|
|
message_id=self.message_id,
|
|
sender=self.sender,
|
|
is_handshake=False,
|
|
userauthdata=userauthdata,
|
|
)
|
|
)
|
|
|
|
message = json.dumps(
|
|
{
|
|
"headerdata": base64.standard_b64encode(headerdata.encode("utf-8")).decode("utf-8"),
|
|
"signature": self.sign(headerdata).decode("utf-8"),
|
|
"mastertoken": self.keys.mastertoken,
|
|
},
|
|
separators=(",", ":"),
|
|
)
|
|
|
|
compressed_data = self.gzip_compress(json.dumps(application_data, separators=(",", ":")).encode("utf-8")).decode("utf-8")
|
|
payloads = [
|
|
{
|
|
"sequencenumber": 1,
|
|
"messageid": self.message_id,
|
|
"compressionalgo": "GZIP",
|
|
"data": compressed_data,
|
|
},
|
|
{
|
|
"sequencenumber": 2,
|
|
"messageid": self.message_id,
|
|
"endofmsg": True,
|
|
"data": "",
|
|
},
|
|
]
|
|
|
|
for payload in payloads:
|
|
encrypted_chunk = self.encrypt(json.dumps(payload, separators=(",", ":")))
|
|
message += json.dumps(
|
|
{
|
|
"payload": base64.standard_b64encode(encrypted_chunk.encode("utf-8")).decode("utf-8"),
|
|
"signature": self.sign(encrypted_chunk).decode("utf-8"),
|
|
},
|
|
separators=(",", ":"),
|
|
)
|
|
return message
|
|
|
|
def parse_message(self, message: str) -> Tuple[Dict[str, Any], Any]:
|
|
parsed = self.parse_concatenated_json(message)
|
|
header = parsed[0]
|
|
payload_chunks = parsed[1:] if len(parsed) > 1 else []
|
|
payload = self.decrypt_payload_chunks(payload_chunks) if payload_chunks else {}
|
|
return header, payload
|
|
|
|
def decrypt_payload_chunks(self, payload_chunks: List[Dict[str, str]]) -> Any:
|
|
if not self.keys.encryption:
|
|
raise ValueError("Encryption key is not available")
|
|
|
|
raw_data = ""
|
|
for payload_chunk in payload_chunks:
|
|
chunk_json = json.loads(base64.standard_b64decode(payload_chunk["payload"]).decode("utf-8"))
|
|
decrypted = AES.new(
|
|
key=self.keys.encryption,
|
|
mode=AES.MODE_CBC,
|
|
iv=base64.standard_b64decode(chunk_json["iv"]),
|
|
).decrypt(base64.standard_b64decode(chunk_json["ciphertext"]))
|
|
decrypted = Padding.unpad(decrypted, 16)
|
|
payload_json = json.loads(decrypted.decode("utf-8"))
|
|
|
|
payload_data = base64.standard_b64decode(payload_json["data"])
|
|
if payload_json.get("compressionalgo") == "GZIP":
|
|
payload_data = zlib.decompress(payload_data, 16 + zlib.MAX_WBITS)
|
|
raw_data += payload_data.decode("utf-8")
|
|
|
|
data = json.loads(raw_data)
|
|
if "error" in data:
|
|
return None
|
|
return data.get("result", data)
|
|
|
|
def encrypt(self, plaintext: str) -> str:
|
|
if not self.keys.encryption:
|
|
raise ValueError("Encryption key is not available")
|
|
if not self.keys.mastertoken:
|
|
raise ValueError("Master token is not available")
|
|
|
|
iv = get_random_bytes(16)
|
|
tokendata = json.loads(base64.standard_b64decode(self.keys.mastertoken["tokendata"]).decode("utf-8"))
|
|
ciphertext = AES.new(self.keys.encryption, AES.MODE_CBC, iv).encrypt(Padding.pad(plaintext.encode("utf-8"), 16))
|
|
return json.dumps(
|
|
{
|
|
"ciphertext": base64.standard_b64encode(ciphertext).decode("utf-8"),
|
|
"keyid": f"{self.sender}_{tokendata['sequencenumber']}",
|
|
"sha256": "AA==",
|
|
"iv": base64.standard_b64encode(iv).decode("utf-8"),
|
|
},
|
|
separators=(",", ":"),
|
|
)
|
|
|
|
def sign(self, text: str) -> bytes:
|
|
if not self.keys.sign:
|
|
raise ValueError("Sign key is not available")
|
|
return base64.standard_b64encode(HMAC.new(self.keys.sign, text.encode("utf-8"), SHA256).digest())
|
|
|
|
@staticmethod
|
|
def parse_concatenated_json(message: str) -> List[Dict[str, Any]]:
|
|
decoder = json.JSONDecoder()
|
|
items: List[Dict[str, Any]] = []
|
|
index = 0
|
|
while index < len(message):
|
|
while index < len(message) and message[index].isspace():
|
|
index += 1
|
|
if index >= len(message):
|
|
break
|
|
item, index = decoder.raw_decode(message, index)
|
|
items.append(item)
|
|
return items
|
|
|
|
@staticmethod
|
|
def gzip_compress(data: bytes) -> bytes:
|
|
out = BytesIO()
|
|
with gzip.GzipFile(fileobj=out, mode="w") as handle:
|
|
handle.write(data)
|
|
return base64.standard_b64encode(out.getvalue())
|
|
|
|
@staticmethod
|
|
def base64key_decode(payload: str) -> bytes:
|
|
remainder = len(payload) % 4
|
|
if remainder == 2:
|
|
payload += "=="
|
|
elif remainder == 3:
|
|
payload += "="
|
|
elif remainder != 0:
|
|
raise ValueError("Invalid base64 string")
|
|
return base64.urlsafe_b64decode(payload.encode("utf-8"))
|
|
|
|
@staticmethod
|
|
def load_cache_data(msl_keys_path: Optional[Path] = None) -> Optional[MSLKeys]:
|
|
if not msl_keys_path or not msl_keys_path.is_file():
|
|
return None
|
|
|
|
msl_keys = jsonpickle.decode(msl_keys_path.read_text(encoding="utf-8"))
|
|
if msl_keys.rsa:
|
|
msl_keys.rsa = RSA.import_key(msl_keys.rsa)
|
|
|
|
if msl_keys.mastertoken:
|
|
tokendata = json.loads(base64.standard_b64decode(msl_keys.mastertoken["tokendata"]).decode("utf-8"))
|
|
renewal_window = datetime.fromtimestamp(int(tokendata["renewalwindow"]), tz=timezone.utc)
|
|
if (renewal_window - datetime.now(timezone.utc)).total_seconds() / 3600 < 10:
|
|
return None
|
|
return msl_keys
|
|
|
|
@staticmethod
|
|
def cache_keys(msl_keys: MSLKeys, msl_keys_path: Path) -> None:
|
|
original_rsa = msl_keys.rsa
|
|
if msl_keys.rsa:
|
|
msl_keys.rsa = msl_keys.rsa.export_key()
|
|
msl_keys_path.write_text(jsonpickle.encode(msl_keys, indent=4), encoding="utf-8")
|
|
if original_rsa:
|
|
msl_keys.rsa = original_rsa
|
|
|
|
@staticmethod
|
|
def cookiejar_to_list(cookie_jar: CookieJar) -> List[Dict[str, Any]]:
|
|
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]:
|
|
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]:
|
|
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:
|
|
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:
|
|
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", {}),
|
|
) |