mirror of
https://github.com/Ap0dexMe0/NF-MSL.git
synced 2026-10-24 16:52:10 +02:00
refactored
This commit is contained in:
+2
-1
@@ -4,4 +4,5 @@ cookies/*
|
||||
venv/*
|
||||
output/*
|
||||
.vscode/*
|
||||
modules/__pycache__/
|
||||
modules/__pycache__/
|
||||
__pycache__/
|
||||
@@ -0,0 +1,512 @@
|
||||
"""
|
||||
helpers.py — Shared utility functions for the NF-MSL project.
|
||||
|
||||
Every function here was extracted from main.py to eliminate code duplication.
|
||||
No behaviour was changed; all original logic is preserved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import uuid
|
||||
from http.cookies import SimpleCookie
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import unpad
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Directory / file helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ensure_output_dir(platform_name, base_dir: Optional[Path] = None) -> Path:
|
||||
"""Create (if needed) and return the ``output`` directory under *base_dir*.
|
||||
|
||||
If *base_dir* is ``None`` it defaults to the directory that contains the
|
||||
calling script (i.e. ``Path(__file__).resolve().parent``).
|
||||
"""
|
||||
if base_dir is None:
|
||||
base_dir = Path(__file__).resolve().parent
|
||||
output_dir = base_dir / "output" / platform_name
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
return output_dir
|
||||
|
||||
|
||||
def save_json(path: Path, data: Any, log: Optional[logging.Logger] = None) -> None:
|
||||
"""Write *data* as pretty-printed JSON to *path*."""
|
||||
try:
|
||||
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
if log:
|
||||
log.info("Saved %s", path.name)
|
||||
except Exception:
|
||||
if log:
|
||||
log.exception("Failed to save %s", path)
|
||||
raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session / cookie helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def restore_auth_cookies(
|
||||
session: requests.Session,
|
||||
cookies_path: Path,
|
||||
log: logging.Logger,
|
||||
) -> None:
|
||||
"""Load previously saved cookies from *cookies_path* into *session*."""
|
||||
if not cookies_path.exists():
|
||||
return
|
||||
try:
|
||||
cached = json.loads(cookies_path.read_text(encoding="utf-8"))
|
||||
if isinstance(cached, dict):
|
||||
session.cookies.update(cached)
|
||||
except Exception as exc:
|
||||
log.warning("Saved auth cookies could not be restored: %s", exc)
|
||||
|
||||
|
||||
def get_cookie_value(session: requests.Session, name: str) -> str:
|
||||
"""Return the value of the first cookie named *name*, or empty string."""
|
||||
for cookie in session.cookies:
|
||||
if cookie.name == name:
|
||||
return cookie.value
|
||||
return ""
|
||||
|
||||
|
||||
def get_nfvdid(session: requests.Session, response: Optional[requests.Response] = None) -> str:
|
||||
"""Extract the ``nfvdid`` cookie from *session* (or *response* as fallback).
|
||||
|
||||
Raises ``RuntimeError`` when the cookie cannot be found.
|
||||
"""
|
||||
nfvdid = ""
|
||||
for cookie in session.cookies:
|
||||
if cookie.name == "nfvdid":
|
||||
nfvdid = cookie.value
|
||||
break
|
||||
if not nfvdid and response is not None:
|
||||
nfvdid = response.cookies.get("nfvdid", "")
|
||||
if not nfvdid:
|
||||
raise RuntimeError("The initial nfvdid cookie was not returned")
|
||||
return nfvdid
|
||||
|
||||
|
||||
def get_flow_session_cookies(session: requests.Session) -> Tuple[str, str]:
|
||||
"""Return ``(nfvdid, flowSessionId)`` from the session cookie jar."""
|
||||
nfvdid = ""
|
||||
flow_session_id = ""
|
||||
for cookie in session.cookies:
|
||||
if cookie.name == "nfvdid":
|
||||
nfvdid = cookie.value
|
||||
elif cookie.name == "flwssn":
|
||||
flow_session_id = cookie.value
|
||||
return nfvdid, flow_session_id
|
||||
|
||||
|
||||
def save_session_cookies(
|
||||
session: requests.Session,
|
||||
path: Path,
|
||||
log: logging.Logger,
|
||||
) -> Dict[str, str]:
|
||||
"""Save all session cookies to *path* as JSON and return the dict."""
|
||||
auth_cookies: Dict[str, str] = {}
|
||||
for cookie in session.cookies:
|
||||
auth_cookies[cookie.name] = cookie.value
|
||||
try:
|
||||
path.write_text(json.dumps(auth_cookies, indent=2), encoding="utf-8")
|
||||
log.info("Authentication cookies saved to: %s", path)
|
||||
except Exception:
|
||||
log.exception("Failed to save cookies")
|
||||
raise
|
||||
return auth_cookies
|
||||
|
||||
|
||||
def build_cookie_header(
|
||||
session: requests.Session,
|
||||
important_names: Tuple[str, ...],
|
||||
) -> str:
|
||||
"""Build a ``Cookie`` header string from *important_names* cookies in *session*.
|
||||
|
||||
Only the first occurrence of each name is included.
|
||||
"""
|
||||
seen: Dict[str, str] = {}
|
||||
for cookie in session.cookies:
|
||||
if cookie.name in important_names and cookie.value and cookie.name not in seen:
|
||||
seen[cookie.name] = cookie.value
|
||||
return "; ".join(f"{n}={seen[n]}" for n in important_names if n in seen)
|
||||
|
||||
|
||||
def apply_set_cookie_headers(
|
||||
session: requests.Session,
|
||||
response: requests.Response,
|
||||
cookie_values: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Parse ``Set-Cookie`` headers from *response* and apply them to *session*.
|
||||
|
||||
*cookie_values* is an optional list that will also be processed (useful
|
||||
when the caller already extracted raw ``Set-Cookie`` strings from
|
||||
``response.raw.headers``).
|
||||
"""
|
||||
values: List[str] = list(cookie_values) if cookie_values else []
|
||||
|
||||
raw_headers = getattr(response.raw, "headers", None)
|
||||
if raw_headers is not None and hasattr(raw_headers, "get_all"):
|
||||
values.extend(raw_headers.get_all("Set-Cookie") or [])
|
||||
header_value = response.headers.get("Set-Cookie")
|
||||
if header_value and header_value not in values:
|
||||
values.append(header_value)
|
||||
|
||||
for raw_cookie in values:
|
||||
jar = SimpleCookie()
|
||||
try:
|
||||
jar.load(raw_cookie)
|
||||
except Exception:
|
||||
continue
|
||||
for morsel in jar.values():
|
||||
cookie_domain = morsel["domain"] or None
|
||||
cookie_path = morsel["path"] or "/"
|
||||
if morsel.value == "":
|
||||
try:
|
||||
session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
try:
|
||||
session.cookies.clear(domain=cookie_domain, path=cookie_path, name=morsel.key)
|
||||
except Exception:
|
||||
pass
|
||||
session.cookies.set(
|
||||
morsel.key, morsel.value,
|
||||
domain=cookie_domain, path=cookie_path,
|
||||
secure=bool(morsel["secure"]),
|
||||
)
|
||||
|
||||
|
||||
def dedupe_important_cookies(
|
||||
session: requests.Session,
|
||||
important_names: Tuple[str, ...],
|
||||
) -> None:
|
||||
"""Remove duplicate cookies so only the best-scoring one per name remains.
|
||||
|
||||
Scoring prefers ``.netflix.com`` domain, ``/`` path, and non-empty value.
|
||||
"""
|
||||
preferred: Dict[str, Any] = {}
|
||||
for cookie in session.cookies:
|
||||
if cookie.name not in important_names:
|
||||
continue
|
||||
current = preferred.get(cookie.name)
|
||||
score = (cookie.domain == ".netflix.com", cookie.path == "/", bool(cookie.value))
|
||||
if current is None or score >= current[0]:
|
||||
preferred[cookie.name] = (score, cookie)
|
||||
|
||||
for cookie in list(session.cookies):
|
||||
winner = preferred.get(cookie.name)
|
||||
if not winner:
|
||||
continue
|
||||
winner_cookie = winner[1]
|
||||
if (cookie.domain, cookie.path, cookie.value) != (
|
||||
winner_cookie.domain, winner_cookie.path, winner_cookie.value,
|
||||
):
|
||||
try:
|
||||
session.cookies.clear(domain=cookie.domain, path=cookie.path, name=cookie.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def collect_important_cookies(
|
||||
session: requests.Session,
|
||||
important_names: Tuple[str, ...],
|
||||
) -> Dict[str, str]:
|
||||
"""Return a flat dict of important cookie name → value (first seen wins)."""
|
||||
cookies: Dict[str, str] = {}
|
||||
for cookie in session.cookies:
|
||||
if cookie.name in important_names and cookie.value and cookie.name not in cookies:
|
||||
cookies[cookie.name] = cookie.value
|
||||
return cookies
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ID / random-string generators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_hex_id(length: int = 32, uppercase: bool = False) -> str:
|
||||
"""Return a random hex string of the given *length*."""
|
||||
charset = "0123456789ABCDEF" if uppercase else "0123456789abcdef"
|
||||
return "".join(random.choice(charset) for _ in range(length))
|
||||
|
||||
|
||||
def generate_netflix_uuid() -> str:
|
||||
"""Return a UUID in the Netflix ``8-4-4-4-12`` hex format."""
|
||||
return (
|
||||
generate_hex_id(8) + "-"
|
||||
+ generate_hex_id(4) + "-"
|
||||
+ generate_hex_id(4) + "-"
|
||||
+ generate_hex_id(4) + "-"
|
||||
+ generate_hex_id(12)
|
||||
)
|
||||
|
||||
|
||||
def generate_request_id() -> str:
|
||||
"""Shorthand for a 32-char lowercase hex request ID."""
|
||||
return generate_hex_id(32)
|
||||
|
||||
|
||||
def generate_esn_random_suffix(length: int = 64) -> str:
|
||||
"""Return a random alphanumeric suffix for ESN generation."""
|
||||
return "".join(random.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(length))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MSL crypto helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_key(raw_key: Any) -> bytes:
|
||||
"""Convert a key value (hex-str, bytes, or raw) to ``bytes``."""
|
||||
if isinstance(raw_key, str):
|
||||
try:
|
||||
return bytes.fromhex(raw_key)
|
||||
except ValueError:
|
||||
return raw_key.encode("utf-8")
|
||||
return raw_key
|
||||
|
||||
|
||||
def decrypt_msl_header(
|
||||
headerdata_b64: str,
|
||||
encryption_key_value: Any,
|
||||
sign_key_value: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Decrypt an MSL encrypted header and return the parsed JSON dict.
|
||||
|
||||
*encryption_key_value* and *sign_key_value* may be hex-strings or bytes.
|
||||
Raises ``RuntimeError`` when either key is missing.
|
||||
"""
|
||||
encryption_key = _resolve_key(encryption_key_value)
|
||||
sign_key = _resolve_key(sign_key_value)
|
||||
|
||||
if not encryption_key:
|
||||
raise RuntimeError("The encryption key is missing")
|
||||
if not sign_key:
|
||||
raise RuntimeError("The sign key is missing")
|
||||
|
||||
encrypted_header = json.loads(base64.b64decode(headerdata_b64))
|
||||
iv = base64.b64decode(encrypted_header["iv"])
|
||||
ciphertext = base64.b64decode(encrypted_header["ciphertext"])
|
||||
|
||||
cipher = AES.new(encryption_key, AES.MODE_CBC, iv)
|
||||
decrypted = unpad(cipher.decrypt(ciphertext), AES.block_size)
|
||||
return json.loads(decrypted.decode("utf-8"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML / response parsing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def extract_first_match(html: str, patterns: List[str]) -> Optional[str]:
|
||||
"""Try each regex *pattern* against *html* and return the first captured group."""
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, html)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
CLCS_SESSION_ID_PATTERNS = [
|
||||
r'"clcsSessionId"\s*:\s*"([0-9a-f\-]{36})"',
|
||||
r'\\"clcsSessionId\\"\s*:\s*\\"([0-9a-f\-]{36})\\"',
|
||||
r'"serverState"\s*:\s*"[^\"]*clcsSessionId\\":\\"([0-9a-f\-]{36})',
|
||||
r'"trackingInfo"\s*:\s*"[^\"]*clcsSessionId\\":\\"([0-9a-f\-]{36})',
|
||||
r'"sessionId"\s*:\s*"([0-9a-f\-]{36})"',
|
||||
]
|
||||
|
||||
RENDITION_ID_PATTERNS = [
|
||||
r'"renditionId"\s*:\s*"([0-9a-f\-]{36})"',
|
||||
r'\\"renditionId\\"\s*:\s*\\"([0-9a-f\-]{36})\\"',
|
||||
]
|
||||
|
||||
|
||||
def extract_clcs_session_id(html: str) -> str:
|
||||
"""Extract ``clcsSessionId`` from login page HTML.
|
||||
|
||||
Raises ``RuntimeError`` when not found.
|
||||
"""
|
||||
result = extract_first_match(html, CLCS_SESSION_ID_PATTERNS)
|
||||
if not result:
|
||||
raise RuntimeError("Could not extract clcsSessionId from the login page HTML")
|
||||
return result
|
||||
|
||||
|
||||
def extract_rendition_id(html: str) -> str:
|
||||
"""Extract ``renditionId`` from login page HTML.
|
||||
|
||||
Raises ``RuntimeError`` when not found.
|
||||
"""
|
||||
result = extract_first_match(html, RENDITION_ID_PATTERNS)
|
||||
if not result:
|
||||
raise RuntimeError("Could not extract the initial renditionId from the login page HTML")
|
||||
return result
|
||||
|
||||
|
||||
def parse_flow_data(response_data: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""Walk a GraphQL response tree and extract flow metadata.
|
||||
|
||||
Returns a dict that may contain keys:
|
||||
``clcsSessionId``, ``renditionId``, ``flowSessionId``, ``mode``,
|
||||
``flow``, ``membershipStatus``.
|
||||
"""
|
||||
flow: Dict[str, str] = {}
|
||||
data = response_data.get("data", {})
|
||||
operation_key = next(iter(data.keys()), "")
|
||||
inner = data.get(operation_key, {})
|
||||
screen = inner.get("screen", inner) if isinstance(inner, dict) else {}
|
||||
|
||||
stack = [screen]
|
||||
while stack:
|
||||
value = stack.pop()
|
||||
if isinstance(value, dict):
|
||||
tracking_info = value.get("trackingInfo")
|
||||
if isinstance(tracking_info, str) and tracking_info:
|
||||
try:
|
||||
tracking = json.loads(tracking_info)
|
||||
except Exception:
|
||||
tracking = {}
|
||||
if tracking.get("clcsSessionId") and not flow.get("clcsSessionId"):
|
||||
flow["clcsSessionId"] = tracking.get("clcsSessionId", "")
|
||||
if tracking.get("clcsRenditionId") and not flow.get("renditionId"):
|
||||
flow["renditionId"] = tracking.get("clcsRenditionId", "")
|
||||
payload_json = value.get("payloadJson")
|
||||
if isinstance(payload_json, str) and payload_json:
|
||||
try:
|
||||
payload = json.loads(payload_json)
|
||||
except Exception:
|
||||
payload = {}
|
||||
if payload.get("flwssn") and not flow.get("flowSessionId"):
|
||||
flow["flowSessionId"] = payload.get("flwssn", "")
|
||||
if payload.get("mode") and not flow.get("mode"):
|
||||
flow["mode"] = payload.get("mode", "")
|
||||
if payload.get("flow") and not flow.get("flow"):
|
||||
flow["flow"] = payload.get("flow", "")
|
||||
if value.get("membershipStatus"):
|
||||
flow["membershipStatus"] = value.get("membershipStatus", "")
|
||||
for child in value.values():
|
||||
stack.append(child)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
stack.append(item)
|
||||
|
||||
return flow
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MSL payload parsing helpers (used by run_tv / run_tv_otp)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_msl_payload(payload: Any) -> Tuple[str, Optional[Dict], Optional[str]]:
|
||||
"""Classify an MSL payload and return ``(type_str, parsed_dict, text_str)``.
|
||||
|
||||
*type_str* is one of ``"msl_payload"``, ``"json_array"``, or ``"text"``.
|
||||
"""
|
||||
payload_type = type(payload).__name__
|
||||
parsed_payload: Optional[Dict] = None
|
||||
text_payload: Optional[str] = None
|
||||
|
||||
if isinstance(payload, dict):
|
||||
payload_type = "msl_payload"
|
||||
parsed_payload = payload
|
||||
elif isinstance(payload, list):
|
||||
payload_type = "json_array"
|
||||
parsed_payload = {"items": payload}
|
||||
elif isinstance(payload, str):
|
||||
cleaned = payload.rstrip(
|
||||
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10"
|
||||
)
|
||||
text_payload = cleaned
|
||||
try:
|
||||
maybe_json = json.loads(cleaned)
|
||||
if isinstance(maybe_json, dict):
|
||||
parsed_payload = maybe_json
|
||||
payload_type = "text"
|
||||
else:
|
||||
payload_type = "text"
|
||||
except Exception:
|
||||
payload_type = "text"
|
||||
|
||||
return payload_type, parsed_payload, text_payload
|
||||
|
||||
|
||||
def extract_useridtoken_from_payload(
|
||||
parsed_payload: Optional[Dict], raw_payload: Any,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Walk the payload tree and return the first dict with both
|
||||
``tokendata`` and ``signature`` keys (the useridtoken)."""
|
||||
useridtoken: Optional[Dict[str, Any]] = None
|
||||
stack = [parsed_payload if parsed_payload is not None else raw_payload]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if isinstance(current, dict):
|
||||
if useridtoken is None and set(current.keys()) >= {"tokendata", "signature"}:
|
||||
useridtoken = current
|
||||
for child in current.values():
|
||||
stack.append(child)
|
||||
elif isinstance(current, list):
|
||||
for item in current:
|
||||
stack.append(item)
|
||||
return useridtoken
|
||||
|
||||
|
||||
def build_msl_trace_event(
|
||||
msl_message_id: Any,
|
||||
key_id: str,
|
||||
payload_type: str,
|
||||
parsed_payload: Optional[Dict],
|
||||
text_payload: Optional[str],
|
||||
extra_fields: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a single MSL trace event dict (used for debug logging)."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
event: Dict[str, Any] = {
|
||||
"_type": "decrypt",
|
||||
"_mslId": msl_message_id,
|
||||
"_timestamp": datetime.now(timezone.utc)
|
||||
.isoformat(timespec="milliseconds")
|
||||
.replace("+00:00", "Z"),
|
||||
"_keyId": key_id,
|
||||
"_dataType": payload_type,
|
||||
}
|
||||
if extra_fields:
|
||||
event.update(extra_fields)
|
||||
if text_payload is not None:
|
||||
event["_text"] = text_payload
|
||||
if parsed_payload is not None:
|
||||
event["_payload"] = parsed_payload
|
||||
return event
|
||||
|
||||
|
||||
def extract_key_id_from_mastertoken(mastertoken: Dict[str, Any]) -> str:
|
||||
"""Extract the hex key ID from a master token's ``tokendata``."""
|
||||
if not mastertoken:
|
||||
return ""
|
||||
token_data = json.loads(base64.b64decode(mastertoken["tokendata"]).decode("utf-8"))
|
||||
return str(token_data.get("sequencenumber", "")).encode("utf-8").hex()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request-args helpers (TV platform)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def request_args_to_dict(request_args: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Convert a list of ``{"name":…, "value": {"stringValue"|"booleanValue":…}}``
|
||||
dicts into a plain ``{name: value}`` mapping."""
|
||||
result: Dict[str, Any] = {}
|
||||
for arg in request_args:
|
||||
value = arg["value"]
|
||||
if "stringValue" in value:
|
||||
result[arg["name"]] = value["stringValue"]
|
||||
elif "booleanValue" in value:
|
||||
result[arg["name"]] = value["booleanValue"]
|
||||
return result
|
||||
+263
-360
@@ -1,28 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import jsonpickle
|
||||
import requests
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from Cryptodome.Cipher import AES
|
||||
from Cryptodome.Hash import HMAC, SHA256
|
||||
from Cryptodome.Cipher import PKCS1_OAEP, AES as _AES
|
||||
from Cryptodome.Hash import SHA1
|
||||
from Cryptodome.PublicKey import RSA
|
||||
from Cryptodome.PublicKey.RSA import RsaKey
|
||||
from Cryptodome.Random import get_random_bytes
|
||||
from Cryptodome.Util import Padding
|
||||
from pywidevine import Cdm as WidevineCdm, Device as WidevineDevice, PSSH
|
||||
|
||||
class MSLObject:
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__} {jsonpickle.encode(self, unpicklable=False)}>"
|
||||
from modules.msl_base import MSLBase, MSLKeys as _BaseMSLKeys, get_widevine_key
|
||||
|
||||
|
||||
class MSLKeys(MSLObject):
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform-specific key container
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MSLKeys(_BaseMSLKeys):
|
||||
"""Android MSL keys – extends the base with an RSA key and CDM session."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
encryption: Optional[bytes] = None,
|
||||
@@ -30,26 +31,40 @@ class MSLKeys(MSLObject):
|
||||
rsa: Optional[RsaKey] = None,
|
||||
mastertoken: Optional[dict] = None,
|
||||
cdm_session: Any = None,
|
||||
):
|
||||
self.encryption = encryption
|
||||
self.sign = sign
|
||||
) -> None:
|
||||
super().__init__(encryption=encryption, sign=sign, mastertoken=mastertoken)
|
||||
self.rsa = rsa
|
||||
self.mastertoken = mastertoken
|
||||
self.cdm_session = cdm_session
|
||||
|
||||
class MSL_ANDROID:
|
||||
DEFAULT_HANDSHAKE_ENDPOINT = "https://android.prod.ftl.netflix.com/nq/androidui/pbo_license/~1.0.0/router"
|
||||
DEFAULT_MANIFEST_ENDPOINT = "https://android.prod.ftl.netflix.com/msl/playapi/android/manifest"
|
||||
DEFAULT_MANIFEST_PARAMS = {
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MSL_ANDROID
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MSL_ANDROID(MSLBase):
|
||||
"""Netflix MSL client for Android devices using Widevine DRM."""
|
||||
|
||||
# -- Platform constants --------------------------------------------------
|
||||
DEFAULT_HANDSHAKE_ENDPOINT: str = (
|
||||
"https://android.prod.ftl.netflix.com/nq/androidui/pbo_license/~1.0.0/router"
|
||||
)
|
||||
DEFAULT_MANIFEST_ENDPOINT: str = (
|
||||
"https://android.prod.ftl.netflix.com/msl/playapi/android/manifest"
|
||||
)
|
||||
DEFAULT_MANIFEST_PARAMS: Dict[str, str] = {
|
||||
"ab_ui_ver": "android",
|
||||
"nrdapp_version": "18.26.0",
|
||||
}
|
||||
DEFAULT_USER_AGENT = "com.netflix.mediaclient/63988 (Linux; U; Android 15; en_US; SM-F711N; Build/AP3A.240905.015.A2; Cronet/143.0.7445.0)"
|
||||
DEFAULT_REQUEST_CONTEXT = '{"appState":"foreground","appView":"unknown"}'
|
||||
DEFAULT_NRDJS_VERSION = "v3.12.55"
|
||||
DEFAULT_NETJS_VERSION = "3.0.5"
|
||||
DEFAULT_PBO_VERSION = 2
|
||||
DEFAULT_PBO_COMMON = {
|
||||
DEFAULT_USER_AGENT: str = (
|
||||
"com.netflix.mediaclient/63988 "
|
||||
"(Linux; U; Android 15; en_US; SM-F711N; "
|
||||
"Build/AP3A.240905.015.A2; Cronet/143.0.7445.0)"
|
||||
)
|
||||
DEFAULT_REQUEST_CONTEXT: str = '{"appState":"foreground","appView":"unknown"}'
|
||||
DEFAULT_NRDJS_VERSION: str = "v3.12.55"
|
||||
DEFAULT_NETJS_VERSION: str = "3.0.5"
|
||||
DEFAULT_PBO_VERSION: int = 2
|
||||
DEFAULT_PBO_COMMON: Dict[str, str] = {
|
||||
"sdk": "18.26.0",
|
||||
"platform": "18.26.0",
|
||||
"application": "Netflix Android 18.26.0",
|
||||
@@ -58,8 +73,10 @@ class MSL_ANDROID:
|
||||
"clientVersion": "18.26.0",
|
||||
"apkVersion": "18.26.0",
|
||||
}
|
||||
DEFAULT_PBO_LANGUAGES = ["en-US", "en"]
|
||||
DEFAULT_DEVICE_MODEL = "SM-F711N"
|
||||
DEFAULT_PBO_LANGUAGES: List[str] = ["en-US", "en"]
|
||||
DEFAULT_DEVICE_MODEL: str = "SM-F711N"
|
||||
|
||||
# -- Constructor ---------------------------------------------------------
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -69,14 +86,13 @@ class MSL_ANDROID:
|
||||
sender: str,
|
||||
user_auth: Optional[dict] = None,
|
||||
drm: str = "widevine",
|
||||
):
|
||||
self.session = session
|
||||
self.keys = keys
|
||||
self.sender = sender
|
||||
) -> None:
|
||||
super().__init__(session=session, keys=keys, message_id=message_id, sender=sender)
|
||||
self.user_auth = user_auth
|
||||
self.message_id = message_id
|
||||
self.drm = drm
|
||||
|
||||
# -- Widevine handshake --------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def handshake(
|
||||
cls,
|
||||
@@ -91,11 +107,12 @@ class MSL_ANDROID:
|
||||
endpoint: Optional[str] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> MSLKeys:
|
||||
"""Perform a Widevine key exchange and return negotiated keys."""
|
||||
if cookies:
|
||||
session.cookies.update(cookies)
|
||||
|
||||
cache_path = Path(msl_keys_path)
|
||||
msl_keys = MSL_ANDROID.load_cache_data(cache_path)
|
||||
msl_keys = cls.load_cache_data(cache_path)
|
||||
if msl_keys is not None and not new_msl:
|
||||
return msl_keys
|
||||
|
||||
@@ -103,7 +120,7 @@ class MSL_ANDROID:
|
||||
raise ValueError(f"Unsupported DRM mode: {drm}")
|
||||
|
||||
if not cdm:
|
||||
raise ValueError("Widevine CDM is required for this iOS MSL flow")
|
||||
raise ValueError("Widevine CDM is required for this Android MSL flow")
|
||||
|
||||
message_id = random.randint(0, pow(2, 52))
|
||||
msl_keys = MSLKeys()
|
||||
@@ -135,7 +152,7 @@ class MSL_ANDROID:
|
||||
},
|
||||
},
|
||||
"headerdata": base64.standard_b64encode(
|
||||
MSL_ANDROID.generate_msg_header(
|
||||
cls.generate_msg_header(
|
||||
message_id=message_id,
|
||||
sender=sender,
|
||||
is_handshake=True,
|
||||
@@ -169,10 +186,14 @@ class MSL_ANDROID:
|
||||
host="android15.prod.cloud.netflix.com",
|
||||
language="en-US,en",
|
||||
)
|
||||
res = session.post(url=handshake_endpoint, data=data, headers=handshake_headers, timeout=30)
|
||||
res = session.post(
|
||||
url=handshake_endpoint, data=data, headers=handshake_headers, timeout=30
|
||||
)
|
||||
|
||||
if res.status_code != 200:
|
||||
raise RuntimeError(f"Key exchange failed: HTTP {res.status_code} {res.text[:500]}")
|
||||
raise RuntimeError(
|
||||
f"Key exchange failed: HTTP {res.status_code} {res.text[:500]}"
|
||||
)
|
||||
|
||||
parsed = cls.parse_concatenated_json(res.text)
|
||||
if not parsed:
|
||||
@@ -181,12 +202,17 @@ class MSL_ANDROID:
|
||||
key_exchange = parsed[0]
|
||||
|
||||
if "errordata" in key_exchange:
|
||||
decoded_error = base64.standard_b64decode(key_exchange["errordata"]).decode("utf-8")
|
||||
decoded_error = base64.standard_b64decode(
|
||||
key_exchange["errordata"]
|
||||
).decode("utf-8")
|
||||
error_json = json.loads(decoded_error)
|
||||
raise RuntimeError(f"Key exchange failed: {error_json}")
|
||||
|
||||
if "headerdata" not in key_exchange:
|
||||
raise RuntimeError(f"Key exchange failed: missing headerdata in response: {str(key_exchange)[:500]}")
|
||||
raise RuntimeError(
|
||||
f"Key exchange failed: missing headerdata in response: "
|
||||
f"{str(key_exchange)[:500]}"
|
||||
)
|
||||
|
||||
header_json = json.loads(
|
||||
base64.standard_b64decode(key_exchange["headerdata"]).decode("utf-8")
|
||||
@@ -195,22 +221,190 @@ class MSL_ANDROID:
|
||||
key_data = key_response_data["keydata"]
|
||||
|
||||
cdm.parse_license(msl_keys.cdm_session, key_data["cdmkeyresponse"])
|
||||
keys = cdm.get_keys(msl_keys.cdm_session)
|
||||
msl_keys.encryption = MSL_ANDROID.get_widevine_key(
|
||||
wv_keys = cdm.get_keys(msl_keys.cdm_session)
|
||||
msl_keys.encryption = get_widevine_key(
|
||||
kid=base64.standard_b64decode(key_data["encryptionkeyid"]),
|
||||
keys=keys,
|
||||
keys=wv_keys,
|
||||
permissions=["AllowEncrypt", "AllowDecrypt"],
|
||||
)
|
||||
msl_keys.sign = MSL_ANDROID.get_widevine_key(
|
||||
msl_keys.sign = get_widevine_key(
|
||||
kid=base64.standard_b64decode(key_data["hmackeyid"]),
|
||||
keys=keys,
|
||||
keys=wv_keys,
|
||||
permissions=["AllowSign", "AllowSignatureVerify"],
|
||||
)
|
||||
|
||||
msl_keys.mastertoken = key_response_data["mastertoken"]
|
||||
MSL_ANDROID.cache_keys(msl_keys, cache_path)
|
||||
cls.cache_keys(msl_keys, cache_path)
|
||||
return msl_keys
|
||||
|
||||
# -- RSA / ASYMMETRIC_WRAPPED handshake (no Widevine required) -----------
|
||||
|
||||
@classmethod
|
||||
def rsa_handshake(
|
||||
cls,
|
||||
msl_keys_path: str,
|
||||
session: requests.Session,
|
||||
sender: str,
|
||||
new_msl: bool,
|
||||
cookies: Optional[Dict[str, str]],
|
||||
endpoint: Optional[str] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> "MSLKeys":
|
||||
"""Perform an ASYMMETRIC_WRAPPED (RSA + JWE) key exchange.
|
||||
|
||||
This path does **not** require a Widevine device file. Netflix wraps
|
||||
the session AES-GCM keys with our ephemeral RSA-OAEP public key and
|
||||
returns them inside a JWE compact serialisation.
|
||||
|
||||
The ESN must use the ``NFCDCH-02-`` prefix (web-style) so that the
|
||||
Android FTL endpoint accepts the ``NONE`` entity auth scheme.
|
||||
"""
|
||||
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
|
||||
|
||||
# ---- Generate ephemeral RSA-2048 keypair ----------------------------
|
||||
rsa_key = RSA.generate(2048)
|
||||
pub_der_b64 = base64.b64encode(
|
||||
rsa_key.publickey().export_key("DER")
|
||||
).decode("ascii")
|
||||
|
||||
message_id = random.randint(0, 2 ** 52)
|
||||
msl_keys = MSLKeys(rsa=rsa_key)
|
||||
|
||||
keyrequestdata = {
|
||||
"scheme": "ASYMMETRIC_WRAPPED",
|
||||
"keydata": {
|
||||
"keypairid": "rsaKeypairId",
|
||||
"mechanism": "JWE_RSA",
|
||||
"publickey": pub_der_b64,
|
||||
},
|
||||
}
|
||||
|
||||
data = 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,
|
||||
)
|
||||
data += json.dumps(
|
||||
{
|
||||
"payload": base64.standard_b64encode(
|
||||
json.dumps(
|
||||
{
|
||||
"messageid": message_id,
|
||||
"data": "",
|
||||
"sequencenumber": 1,
|
||||
"endofmsg": True,
|
||||
}
|
||||
).encode("utf-8")
|
||||
).decode("utf-8"),
|
||||
"signature": "",
|
||||
}
|
||||
)
|
||||
|
||||
handshake_endpoint = endpoint or cls.DEFAULT_HANDSHAKE_ENDPOINT
|
||||
handshake_headers = headers or cls.build_request_headers(
|
||||
request_name="mintCookies",
|
||||
esn=sender,
|
||||
host="android15.prod.cloud.netflix.com",
|
||||
language="en-US,en",
|
||||
)
|
||||
res = session.post(
|
||||
url=handshake_endpoint,
|
||||
data=data,
|
||||
headers=handshake_headers,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if res.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"RSA key exchange failed: HTTP {res.status_code} {res.text[:500]}"
|
||||
)
|
||||
|
||||
parsed = cls.parse_concatenated_json(res.text)
|
||||
if not parsed:
|
||||
raise RuntimeError("RSA key exchange failed: empty MSL response")
|
||||
|
||||
key_exchange = parsed[0]
|
||||
|
||||
if "errordata" in key_exchange:
|
||||
decoded_error = base64.standard_b64decode(
|
||||
key_exchange["errordata"]
|
||||
).decode("utf-8")
|
||||
raise RuntimeError(
|
||||
f"RSA key exchange failed: {json.loads(decoded_error)}"
|
||||
)
|
||||
|
||||
if "headerdata" not in key_exchange:
|
||||
raise RuntimeError(
|
||||
f"RSA key exchange failed: missing headerdata in response: "
|
||||
f"{str(key_exchange)[:500]}"
|
||||
)
|
||||
|
||||
header_json = json.loads(
|
||||
base64.standard_b64decode(key_exchange["headerdata"]).decode("utf-8")
|
||||
)
|
||||
key_response_data = header_json["keyresponsedata"]
|
||||
key_data = key_response_data["keydata"]
|
||||
|
||||
# ---- Decrypt JWE-wrapped session keys -------------------------------
|
||||
msl_keys.encryption = cls._decrypt_jwe_key(
|
||||
key_data["encryptionkey"], rsa_key
|
||||
)
|
||||
msl_keys.sign = cls._decrypt_jwe_key(key_data["hmackey"], rsa_key)
|
||||
msl_keys.mastertoken = key_response_data["mastertoken"]
|
||||
|
||||
# Don't persist the RSA key object (not picklable); clear it before caching
|
||||
msl_keys.rsa = None
|
||||
cls.cache_keys(msl_keys, cache_path)
|
||||
return msl_keys
|
||||
|
||||
@staticmethod
|
||||
def _decrypt_jwe_key(field_b64: str, rsa_private: RsaKey) -> bytes:
|
||||
"""Decrypt a Netflix JWE-wrapped session key.
|
||||
|
||||
The field value is standard-base64-encoded JWE compact serialisation.
|
||||
Netflix uses RSA-OAEP (SHA-1) for the CEK and A128GCM for content
|
||||
but skips the AAD in the GCM tag — so we decrypt without verification
|
||||
and trust the RSA layer for integrity.
|
||||
"""
|
||||
jwe_compact = base64.b64decode(field_b64 + "==").decode("utf-8")
|
||||
hdr_b64, enc_cek_b64, iv_b64, ct_b64, _ = jwe_compact.split(".")
|
||||
|
||||
# Decrypt the Content Encryption Key (CEK) with RSA-OAEP / SHA-1
|
||||
enc_cek = base64.urlsafe_b64decode(enc_cek_b64 + "==")
|
||||
cek = PKCS1_OAEP.new(rsa_private, hashAlgo=SHA1).decrypt(enc_cek)
|
||||
|
||||
# Decrypt the payload with AES-128-GCM (no AAD verification)
|
||||
iv = base64.urlsafe_b64decode(iv_b64 + "==")
|
||||
ct = base64.urlsafe_b64decode(ct_b64 + "==")
|
||||
plaintext = _AES.new(cek, _AES.MODE_GCM, nonce=iv).decrypt(ct)
|
||||
|
||||
# Payload is a JWK JSON — extract the raw key bytes
|
||||
jwk = json.loads(plaintext.decode("utf-8"))
|
||||
remainder = len(jwk["k"]) % 4
|
||||
padded = jwk["k"] + "=" * (4 - remainder if remainder else 0)
|
||||
return base64.urlsafe_b64decode(padded)
|
||||
|
||||
# -- Platform-specific request headers -----------------------------------
|
||||
|
||||
@staticmethod
|
||||
def build_request_headers(
|
||||
request_name: str,
|
||||
@@ -225,6 +419,7 @@ class MSL_ANDROID:
|
||||
language: Optional[str] = "en-US",
|
||||
device_model: Optional[str] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""Build the HTTP headers required for Android MSL requests."""
|
||||
headers: Dict[str, str] = {
|
||||
"Host": host or "android15.prod.cloud.netflix.com",
|
||||
"Accept": "*/*",
|
||||
@@ -237,7 +432,9 @@ class MSL_ANDROID:
|
||||
"X-DeviceModel": device_model or MSL_ANDROID.DEFAULT_DEVICE_MODEL,
|
||||
"x-netflix.client.request.name": request_name,
|
||||
"x-netflix.request.attempt": "1",
|
||||
"x-netflix.request.id": "".join(random.choice("0123456789abcdef") for _ in range(32)),
|
||||
"x-netflix.request.id": "".join(
|
||||
random.choice("0123456789abcdef") for _ in range(32)
|
||||
),
|
||||
"x-netflix.request.client.context": MSL_ANDROID.DEFAULT_REQUEST_CONTEXT,
|
||||
"x-netflix.request.client.languages": "en-US",
|
||||
"x-netflix.request.client.timezoneid": "America/New_York",
|
||||
@@ -273,50 +470,14 @@ class MSL_ANDROID:
|
||||
headers.update(extra_headers)
|
||||
return headers
|
||||
|
||||
# -- Manifest defaults ---------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def manifest_request_defaults() -> Tuple[str, Dict[str, str]]:
|
||||
"""Return the default manifest endpoint and query params for Android."""
|
||||
return MSL_ANDROID.DEFAULT_MANIFEST_ENDPOINT, dict(MSL_ANDROID.DEFAULT_MANIFEST_PARAMS)
|
||||
|
||||
@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": ["en-US", "en"],
|
||||
"encoderformats": ["JSON"],
|
||||
},
|
||||
"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)
|
||||
|
||||
@staticmethod
|
||||
def get_widevine_key(kid: bytes, keys: List[Any], permissions: List[str]) -> Optional[bytes]:
|
||||
import re
|
||||
normalized_perms = {re.sub(r'(?<!^)(?=[A-Z])', '_', p).lower() for p in permissions}
|
||||
for key in keys:
|
||||
if key.type != "OPERATOR_SESSION":
|
||||
continue
|
||||
key_perms = {p.lower() for p in (getattr(key, "permissions", None) or [])}
|
||||
if normalized_perms <= key_perms:
|
||||
return key.key
|
||||
return None
|
||||
# -- Override send_message to add PBO normalisation ----------------------
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
@@ -326,277 +487,19 @@ class MSL_ANDROID:
|
||||
userauthdata: Optional[dict] = None,
|
||||
headers: Optional[dict] = None,
|
||||
proxy: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 30,
|
||||
) -> Tuple[Dict[str, Any], Any]:
|
||||
normalized_application_data = self.normalize_application_data(endpoint, application_data)
|
||||
message = self.create_message(normalized_application_data, userauthdata)
|
||||
request_kwargs: Dict[str, Any] = {
|
||||
"url": endpoint,
|
||||
"data": message,
|
||||
"params": params,
|
||||
"headers": headers,
|
||||
"timeout": 30,
|
||||
}
|
||||
if proxy:
|
||||
request_kwargs["proxies"] = proxy
|
||||
|
||||
res = self.session.post(**request_kwargs)
|
||||
|
||||
if res.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"MSL request failed with HTTP {res.status_code}: {res.text[:500]}"
|
||||
)
|
||||
|
||||
response_text = res.text or ""
|
||||
stripped_response = response_text.lstrip()
|
||||
if not stripped_response:
|
||||
raise RuntimeError("MSL request failed: empty response body")
|
||||
|
||||
if not stripped_response.startswith("{"):
|
||||
content_type = res.headers.get("content-type", "")
|
||||
raise RuntimeError(
|
||||
"MSL request failed: the server did not return concatenated MSL JSON. "
|
||||
f"Content-Type: {content_type!r}. Body preview: {response_text[:500]!r}"
|
||||
)
|
||||
|
||||
header, payload_data = self.parse_message(response_text)
|
||||
if not header:
|
||||
raise RuntimeError(
|
||||
f"MSL request failed: parsed response does not contain a header. Body preview: {response_text[:500]!r}"
|
||||
)
|
||||
|
||||
if "errordata" in header:
|
||||
decoded_error = json.loads(
|
||||
base64.standard_b64decode(header["errordata"].encode("utf-8")).decode("utf-8")
|
||||
)
|
||||
raise RuntimeError(f"MSL response contains an error: {decoded_error}")
|
||||
|
||||
return header, payload_data
|
||||
|
||||
@classmethod
|
||||
def normalize_application_data(cls, endpoint: str, application_data: Any) -> Any:
|
||||
if not isinstance(application_data, dict):
|
||||
return application_data
|
||||
|
||||
if cls._looks_like_wrapped_pbo_payload(application_data):
|
||||
return application_data
|
||||
|
||||
route = cls._extract_pbo_route(application_data, endpoint)
|
||||
if route is None:
|
||||
return application_data
|
||||
|
||||
common = dict(cls.DEFAULT_PBO_COMMON)
|
||||
if isinstance(application_data.get("common"), dict):
|
||||
common.update(application_data["common"])
|
||||
|
||||
wrapped: Dict[str, Any] = {
|
||||
"version": application_data.get("version", cls.DEFAULT_PBO_VERSION),
|
||||
"common": common,
|
||||
"url": route,
|
||||
"languages": application_data.get("languages", list(cls.DEFAULT_PBO_LANGUAGES)),
|
||||
"params": application_data.get("params", {}),
|
||||
}
|
||||
|
||||
for key in ("path", "method", "route", "endpoint"):
|
||||
wrapped.pop(key, None)
|
||||
|
||||
for key, value in application_data.items():
|
||||
if key in wrapped or key in {"version", "common", "languages", "params", "path", "method", "route", "endpoint"}:
|
||||
continue
|
||||
wrapped[key] = value
|
||||
return wrapped
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_wrapped_pbo_payload(application_data: Dict[str, Any]) -> bool:
|
||||
return (
|
||||
"version" in application_data
|
||||
and "common" in application_data
|
||||
and "url" in application_data
|
||||
and "languages" in application_data
|
||||
and "params" in application_data
|
||||
"""Send an MSL message with PBO payload normalisation."""
|
||||
normalized = self.normalize_application_data(endpoint, application_data)
|
||||
return super().send_message(
|
||||
endpoint=endpoint,
|
||||
params=params,
|
||||
application_data=normalized,
|
||||
userauthdata=userauthdata,
|
||||
headers=headers,
|
||||
proxy=proxy,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_pbo_route(application_data: Dict[str, Any], endpoint: str) -> Optional[str]:
|
||||
for key in ("url", "path", "route", "method", "endpoint"):
|
||||
value = application_data.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
continue
|
||||
value = value.strip()
|
||||
return value if value.startswith("/") else f"/{value}"
|
||||
|
||||
endpoint_lower = endpoint.lower()
|
||||
if "manifest" in endpoint_lower:
|
||||
return "/manifest"
|
||||
if "pbo_tokens" in endpoint_lower or "pbo_config" in endpoint_lower:
|
||||
return None
|
||||
return None
|
||||
|
||||
def create_message(self, application_data: Dict[str, 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_application_data = self.gzip_compress(
|
||||
json.dumps(application_data, separators=(",", ":")).encode("utf-8")
|
||||
).decode("utf-8")
|
||||
payload_dicts = [
|
||||
{
|
||||
"sequencenumber": 1,
|
||||
"messageid": self.message_id,
|
||||
"compressionalgo": "GZIP",
|
||||
"data": compressed_application_data,
|
||||
},
|
||||
{
|
||||
"sequencenumber": 2,
|
||||
"messageid": self.message_id,
|
||||
"endofmsg": True,
|
||||
"data": "",
|
||||
},
|
||||
]
|
||||
|
||||
for payload_dict in payload_dicts:
|
||||
payload_chunk = self.encrypt(json.dumps(payload_dict, separators=(",", ":")))
|
||||
message += json.dumps(
|
||||
{
|
||||
"payload": base64.standard_b64encode(payload_chunk.encode("utf-8")).decode("utf-8"),
|
||||
"signature": self.sign(payload_chunk).decode("utf-8"),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return message
|
||||
|
||||
def decrypt_payload_chunks(self, payload_chunks: List[Dict[str, str]]) -> Any:
|
||||
raw_data = ""
|
||||
assert self.keys.encryption is not None
|
||||
|
||||
for payload_chunk in payload_chunks:
|
||||
payload_chunk_json = json.loads(base64.standard_b64decode(payload_chunk["payload"]).decode("utf-8"))
|
||||
payload_decrypted = AES.new(
|
||||
key=self.keys.encryption,
|
||||
mode=AES.MODE_CBC,
|
||||
iv=base64.standard_b64decode(payload_chunk_json["iv"]),
|
||||
).decrypt(base64.standard_b64decode(payload_chunk_json["ciphertext"]))
|
||||
payload_decrypted = Padding.unpad(payload_decrypted, 16)
|
||||
payload_decrypted_json = json.loads(payload_decrypted.decode("utf-8"))
|
||||
|
||||
payload_data = base64.standard_b64decode(payload_decrypted_json["data"])
|
||||
if payload_decrypted_json.get("compressionalgo") == "GZIP":
|
||||
payload_data = zlib.decompress(payload_data, 16 + zlib.MAX_WBITS)
|
||||
raw_data += payload_data.decode("utf-8")
|
||||
|
||||
if not raw_data:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(raw_data)
|
||||
except Exception:
|
||||
return raw_data
|
||||
|
||||
if "error" in data:
|
||||
return None
|
||||
if "result" not in data:
|
||||
return data
|
||||
return data["result"]
|
||||
|
||||
@staticmethod
|
||||
def parse_concatenated_json(message: str) -> List[Dict[str, Any]]:
|
||||
decoder = json.JSONDecoder()
|
||||
items: List[Dict[str, Any]] = []
|
||||
index = 0
|
||||
length = len(message)
|
||||
|
||||
while index < length:
|
||||
while index < length and message[index].isspace():
|
||||
index += 1
|
||||
if index >= length:
|
||||
break
|
||||
item, next_index = decoder.raw_decode(message, index)
|
||||
items.append(item)
|
||||
index = next_index
|
||||
return items
|
||||
|
||||
def parse_message(self, message: str) -> Tuple[Dict[str, Any], Any]:
|
||||
parsed_message = self.parse_concatenated_json(message)
|
||||
header = parsed_message[0]
|
||||
encrypted_payload_chunks = parsed_message[1:] if len(parsed_message) > 1 else []
|
||||
payload_chunks = self.decrypt_payload_chunks(encrypted_payload_chunks) if encrypted_payload_chunks else {}
|
||||
return header, payload_chunks
|
||||
|
||||
@staticmethod
|
||||
def gzip_compress(data: bytes) -> bytes:
|
||||
out = BytesIO()
|
||||
with gzip.GzipFile(fileobj=out, mode="w") as gzip_file:
|
||||
gzip_file.write(data)
|
||||
return base64.standard_b64encode(out.getvalue())
|
||||
|
||||
@staticmethod
|
||||
def base64key_decode(payload: str) -> bytes:
|
||||
length = len(payload) % 4
|
||||
if length == 2:
|
||||
payload += "=="
|
||||
elif length == 3:
|
||||
payload += "="
|
||||
elif length != 0:
|
||||
raise ValueError("Invalid base64 string")
|
||||
return base64.urlsafe_b64decode(payload.encode("utf-8"))
|
||||
|
||||
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"))
|
||||
return json.dumps(
|
||||
{
|
||||
"ciphertext": base64.standard_b64encode(
|
||||
AES.new(self.keys.encryption, AES.MODE_CBC, iv).encrypt(
|
||||
Padding.pad(plaintext.encode("utf-8"), 16)
|
||||
)
|
||||
).decode("utf-8"),
|
||||
"keyid": f"{self.sender}_{tokendata['sequencenumber']}",
|
||||
"sha256": "AA==",
|
||||
"iv": base64.standard_b64encode(iv).decode("utf-8"),
|
||||
}
|
||||
)
|
||||
|
||||
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 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.mastertoken:
|
||||
tokendata = json.loads(base64.standard_b64decode(msl_keys.mastertoken["tokendata"]).decode("utf-8"))
|
||||
renewal_window = datetime.fromtimestamp(int(tokendata["renewalwindow"]), tz=timezone.utc)
|
||||
remaining_hours = (renewal_window - datetime.now(timezone.utc)).total_seconds() / 3600
|
||||
if remaining_hours < 10:
|
||||
return None
|
||||
return msl_keys
|
||||
|
||||
@staticmethod
|
||||
def cache_keys(msl_keys: MSLKeys, msl_keys_path: Path) -> None:
|
||||
with open(msl_keys_path, "w", encoding="utf-8") as cache_file:
|
||||
cache_file.write(jsonpickle.encode(msl_keys, indent=4))
|
||||
__all__ = ["MSL_ANDROID", "MSLKeys"]
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import zlib
|
||||
|
||||
import jsonpickle
|
||||
import requests
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from Cryptodome.Cipher import AES
|
||||
from Cryptodome.Hash import HMAC, SHA256
|
||||
from Cryptodome.Random import get_random_bytes
|
||||
from Cryptodome.Util import Padding
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core data objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MSLObject:
|
||||
"""Base class providing a compact JSON repr for debugging."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__} {jsonpickle.encode(self, unpicklable=False)}>"
|
||||
|
||||
|
||||
class MSLKeys(MSLObject):
|
||||
"""Holds the negotiated encryption / signing keys and master token."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
encryption: Optional[bytes] = None,
|
||||
sign: Optional[bytes] = None,
|
||||
mastertoken: Optional[dict] = None,
|
||||
) -> None:
|
||||
self.encryption = encryption
|
||||
self.sign = sign
|
||||
self.mastertoken = mastertoken
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widevine key helper (shared by Android, iOS, TV)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_widevine_key(kid: bytes, keys: List[Any], permissions: List[str]) -> Optional[bytes]:
|
||||
"""Find the Widevine operator-session key whose permissions are a
|
||||
superset of *permissions*. Permission names are normalised from
|
||||
CamelCase to snake_case before comparison."""
|
||||
normalized_perms = {re.sub(r'(?<!^)(?=[A-Z])', '_', p).lower() for p in permissions}
|
||||
for key in keys:
|
||||
if key.type != "OPERATOR_SESSION":
|
||||
continue
|
||||
key_perms = {p.lower() for p in (getattr(key, "permissions", None) or [])}
|
||||
if normalized_perms <= key_perms:
|
||||
return key.key
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MSLBase – the shared foundation for all platform MSL classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MSLBase:
|
||||
"""
|
||||
Base class encapsulating the common MSL protocol logic shared by every
|
||||
platform variant (Android, iOS, TV, Web).
|
||||
|
||||
Subclasses override:
|
||||
- class-level DEFAULT_* constants
|
||||
- ``build_request_headers()`` (platform-specific header set)
|
||||
- ``handshake()`` (key-exchange / DRM negotiation)
|
||||
- optionally ``generate_msg_header()`` for platform quirks
|
||||
"""
|
||||
|
||||
# --- Subclasses must override these ------------------------------------
|
||||
DEFAULT_HANDSHAKE_ENDPOINT: str = ""
|
||||
DEFAULT_USER_AGENT: str = ""
|
||||
DEFAULT_REQUEST_CONTEXT: str = "{}"
|
||||
|
||||
# --- Shared protocol constants -----------------------------------------
|
||||
DEFAULT_PBO_VERSION: int = 2
|
||||
DEFAULT_PBO_LANGUAGES: List[str] = ["en-US", "en"]
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Constructor
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: requests.Session,
|
||||
keys: MSLKeys,
|
||||
message_id: int,
|
||||
sender: str,
|
||||
**_kwargs: Any,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.keys = keys
|
||||
self.sender = sender
|
||||
self.message_id = message_id
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# JSON helpers
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def parse_concatenated_json(message: str) -> List[Dict[str, Any]]:
|
||||
"""Parse concatenated JSON objects from a single string (MSL wire format)."""
|
||||
decoder = json.JSONDecoder()
|
||||
items: List[Dict[str, Any]] = []
|
||||
index = 0
|
||||
length = len(message)
|
||||
while index < length:
|
||||
while index < length and message[index].isspace():
|
||||
index += 1
|
||||
if index >= length:
|
||||
break
|
||||
item, next_index = decoder.raw_decode(message, index)
|
||||
items.append(item)
|
||||
index = next_index
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def stable_json(obj: Dict[str, Any]) -> str:
|
||||
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Base64 helpers
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def b64_encode_bytes(value: bytes) -> str:
|
||||
return base64.b64encode(value).decode("ascii")
|
||||
|
||||
@staticmethod
|
||||
def base64key_decode(payload: str) -> bytes:
|
||||
"""Decode a URL-safe base64 JWK key, padding if necessary."""
|
||||
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"))
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Compression
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def gzip_compress(data: bytes) -> bytes:
|
||||
out = BytesIO()
|
||||
with gzip.GzipFile(fileobj=out, mode="w") as gz:
|
||||
gz.write(data)
|
||||
return base64.standard_b64encode(out.getvalue())
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Encryption / Signing
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# AES-CBC convenience (used by MGK branch)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def aes_cbc_encrypt(key: bytes, iv: bytes, plaintext: bytes) -> bytes:
|
||||
return AES.new(key, AES.MODE_CBC, iv).encrypt(Padding.pad(plaintext, AES.block_size))
|
||||
|
||||
@staticmethod
|
||||
def aes_cbc_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes:
|
||||
return Padding.unpad(
|
||||
AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext),
|
||||
AES.block_size,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Message creation
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@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",
|
||||
languages: Optional[List[str]] = None,
|
||||
recipient: Optional[str] = "Netflix",
|
||||
) -> str:
|
||||
header_data: Dict[str, Any] = {
|
||||
"messageid": message_id,
|
||||
"renewable": True,
|
||||
"handshake": is_handshake,
|
||||
"capabilities": {
|
||||
"compressionalgos": [compression] if compression else [],
|
||||
"languages": languages or ["en-US", "en"],
|
||||
"encoderformats": ["JSON"],
|
||||
},
|
||||
"timestamp": int(datetime.now(timezone.utc).timestamp()),
|
||||
"sender": sender,
|
||||
"nonreplayable": False,
|
||||
}
|
||||
if recipient:
|
||||
header_data["recipient"] = recipient
|
||||
if userauthdata:
|
||||
header_data["userauthdata"] = userauthdata
|
||||
if keyrequestdata:
|
||||
header_data["keyrequestdata"] = [keyrequestdata]
|
||||
return jsonpickle.encode(header_data, unpicklable=False)
|
||||
|
||||
def create_message(
|
||||
self,
|
||||
application_data: Dict[str, 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_application_data = self.gzip_compress(
|
||||
json.dumps(application_data, separators=(",", ":")).encode("utf-8")
|
||||
).decode("utf-8")
|
||||
|
||||
payload_dicts = [
|
||||
{
|
||||
"sequencenumber": 1,
|
||||
"messageid": self.message_id,
|
||||
"compressionalgo": "GZIP",
|
||||
"data": compressed_application_data,
|
||||
},
|
||||
{
|
||||
"sequencenumber": 2,
|
||||
"messageid": self.message_id,
|
||||
"endofmsg": True,
|
||||
"data": "",
|
||||
},
|
||||
]
|
||||
|
||||
for payload_dict in payload_dicts:
|
||||
payload_chunk = self.encrypt(json.dumps(payload_dict, separators=(",", ":")))
|
||||
message += json.dumps(
|
||||
{
|
||||
"payload": base64.standard_b64encode(payload_chunk.encode("utf-8")).decode("utf-8"),
|
||||
"signature": self.sign(payload_chunk).decode("utf-8"),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return message
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Message parsing / decryption
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
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")
|
||||
|
||||
if not raw_data:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(raw_data)
|
||||
except Exception:
|
||||
return raw_data
|
||||
|
||||
if "error" in data:
|
||||
return None
|
||||
if "result" not in data:
|
||||
return data
|
||||
return data["result"]
|
||||
|
||||
def parse_message(self, message: str) -> Tuple[Dict[str, Any], Any]:
|
||||
parsed = self.parse_concatenated_json(message)
|
||||
header = parsed[0]
|
||||
encrypted_chunks = parsed[1:] if len(parsed) > 1 else []
|
||||
payload = self.decrypt_payload_chunks(encrypted_chunks) if encrypted_chunks else {}
|
||||
return header, payload
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Send message (common path – subclasses may override for extra checks)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: Dict[str, str],
|
||||
application_data: Dict[str, Any],
|
||||
userauthdata: Optional[dict] = None,
|
||||
headers: Optional[dict] = None,
|
||||
proxy: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 30,
|
||||
) -> Tuple[Dict[str, Any], Any]:
|
||||
message = self.create_message(application_data, userauthdata)
|
||||
request_kwargs: Dict[str, Any] = {
|
||||
"url": endpoint,
|
||||
"data": message,
|
||||
"params": params,
|
||||
"headers": headers,
|
||||
"timeout": timeout,
|
||||
}
|
||||
if proxy:
|
||||
request_kwargs["proxies"] = proxy
|
||||
|
||||
res = self.session.post(**request_kwargs)
|
||||
|
||||
if res.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"MSL request failed with HTTP {res.status_code}: {res.text[:500]}"
|
||||
)
|
||||
|
||||
response_text = res.text or ""
|
||||
stripped = response_text.lstrip()
|
||||
if not stripped:
|
||||
raise RuntimeError("MSL request failed: empty response body")
|
||||
if not stripped.startswith("{"):
|
||||
raise RuntimeError(
|
||||
"MSL request failed: the server did not return concatenated MSL JSON. "
|
||||
f"Content-Type: {res.headers.get('content-type', '')!r}. "
|
||||
f"Body preview: {response_text[:500]!r}"
|
||||
)
|
||||
|
||||
header, payload_data = self.parse_message(response_text)
|
||||
if not header:
|
||||
raise RuntimeError(
|
||||
f"MSL request failed: parsed response does not contain a header. "
|
||||
f"Body preview: {response_text[:500]!r}"
|
||||
)
|
||||
if "errordata" in header:
|
||||
decoded_error = json.loads(
|
||||
base64.standard_b64decode(header["errordata"].encode("utf-8")).decode("utf-8")
|
||||
)
|
||||
raise RuntimeError(f"MSL response contains an error: {decoded_error}")
|
||||
|
||||
return header, payload_data
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# PBO payload normalisation (shared by Android, iOS, TV)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def normalize_application_data(cls, endpoint: str, application_data: Any) -> Any:
|
||||
if not isinstance(application_data, dict):
|
||||
return application_data
|
||||
if cls._looks_like_wrapped_pbo_payload(application_data):
|
||||
return application_data
|
||||
|
||||
route = cls._extract_pbo_route(application_data, endpoint)
|
||||
if route is None:
|
||||
return application_data
|
||||
|
||||
common = dict(getattr(cls, "DEFAULT_PBO_COMMON", {}))
|
||||
if isinstance(application_data.get("common"), dict):
|
||||
common.update(application_data["common"])
|
||||
|
||||
wrapped: Dict[str, Any] = {
|
||||
"version": application_data.get("version", cls.DEFAULT_PBO_VERSION),
|
||||
"common": common,
|
||||
"url": route,
|
||||
"languages": application_data.get("languages", list(cls.DEFAULT_PBO_LANGUAGES)),
|
||||
"params": application_data.get("params", {}),
|
||||
}
|
||||
|
||||
for key in ("path", "method", "route", "endpoint"):
|
||||
wrapped.pop(key, None)
|
||||
|
||||
for key, value in application_data.items():
|
||||
if key in wrapped or key in {
|
||||
"version", "common", "languages", "params",
|
||||
"path", "method", "route", "endpoint",
|
||||
}:
|
||||
continue
|
||||
wrapped[key] = value
|
||||
return wrapped
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_wrapped_pbo_payload(application_data: Dict[str, Any]) -> bool:
|
||||
return (
|
||||
"version" in application_data
|
||||
and "common" in application_data
|
||||
and "url" in application_data
|
||||
and "languages" in application_data
|
||||
and "params" in application_data
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_pbo_route(application_data: Dict[str, Any], endpoint: str) -> Optional[str]:
|
||||
for key in ("url", "path", "route", "method", "endpoint"):
|
||||
value = application_data.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
continue
|
||||
value = value.strip()
|
||||
return value if value.startswith("/") else f"/{value}"
|
||||
|
||||
endpoint_lower = endpoint.lower()
|
||||
if "manifest" in endpoint_lower:
|
||||
return "/manifest"
|
||||
if "pbo_tokens" in endpoint_lower or "pbo_config" in endpoint_lower:
|
||||
return None
|
||||
return None
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Cache I/O
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def load_cache_data(msl_keys_path: Optional[Path] = None) -> Optional[MSLKeys]:
|
||||
"""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():
|
||||
return None
|
||||
|
||||
msl_keys = jsonpickle.decode(msl_keys_path.read_text(encoding="utf-8"))
|
||||
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
|
||||
)
|
||||
remaining_hours = (renewal_window - datetime.now(timezone.utc)).total_seconds() / 3600
|
||||
if remaining_hours < 10:
|
||||
return None
|
||||
return msl_keys
|
||||
|
||||
@staticmethod
|
||||
def cache_keys(msl_keys: MSLKeys, msl_keys_path: Path) -> None:
|
||||
"""Persist *msl_keys* to *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")
|
||||
+392
-407
File diff suppressed because it is too large
Load Diff
+142
-329
@@ -1,30 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
import zlib
|
||||
import jsonpickle
|
||||
import requests
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from Cryptodome.Cipher import AES, PKCS1_OAEP
|
||||
from Cryptodome.Hash import HMAC, SHA256
|
||||
|
||||
import jsonpickle
|
||||
import requests
|
||||
from Cryptodome.Cipher import PKCS1_OAEP
|
||||
from Cryptodome.PublicKey import RSA
|
||||
from Cryptodome.PublicKey.RSA import RsaKey
|
||||
from Cryptodome.Random import get_random_bytes
|
||||
from Cryptodome.Util import Padding
|
||||
from pywidevine import Cdm as WidevineCdm, Device as WidevineDevice, PSSH
|
||||
|
||||
|
||||
class MSLObject:
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__} {jsonpickle.encode(self, unpicklable=False)}>"
|
||||
from .msl_base import MSLBase, MSLKeys as _BaseMSLKeys, get_widevine_key
|
||||
|
||||
|
||||
class MSLKeys(MSLObject):
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform-specific key container
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MSLKeys(_BaseMSLKeys):
|
||||
"""TV MSL keys – extends the base with an RSA key and CDM session."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
encryption: Optional[bytes] = None,
|
||||
@@ -32,41 +30,59 @@ class MSLKeys(MSLObject):
|
||||
rsa: Optional[RsaKey] = None,
|
||||
mastertoken: Optional[dict] = None,
|
||||
cdm_session: Any = None,
|
||||
):
|
||||
self.encryption = encryption
|
||||
self.sign = sign
|
||||
) -> None:
|
||||
super().__init__(encryption=encryption, sign=sign, mastertoken=mastertoken)
|
||||
self.rsa = rsa
|
||||
self.mastertoken = mastertoken
|
||||
self.cdm_session = cdm_session
|
||||
|
||||
|
||||
class MSL_TV:
|
||||
DEFAULT_HANDSHAKE_ENDPOINT = "https://nrdp25.prod.ftl.netflix.com/nq/nrdjs/pbo_tokens/%5E1.0.0/router"
|
||||
DEFAULT_MANIFEST_ENDPOINT = "https://api-global.netflix.com/playapi/nrdjs/manifest/1"
|
||||
DEFAULT_MANIFEST_PARAMS = {
|
||||
# ---------------------------------------------------------------------------
|
||||
# MSL_TV
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MSL_TV(MSLBase):
|
||||
"""Netflix MSL client for TV devices, supporting both Widevine and RSA."""
|
||||
|
||||
# -- Platform constants --------------------------------------------------
|
||||
DEFAULT_HANDSHAKE_ENDPOINT: str = (
|
||||
"https://nrdp25.prod.ftl.netflix.com/nq/nrdjs/pbo_tokens/%5E1.0.0/router"
|
||||
)
|
||||
DEFAULT_MANIFEST_ENDPOINT: str = (
|
||||
"https://api-global.netflix.com/playapi/nrdjs/manifest/1"
|
||||
)
|
||||
DEFAULT_MANIFEST_PARAMS: Dict[str, str] = {
|
||||
"ab_ui_ver": "darwin",
|
||||
"nrdapp_version": "2025.2.3.0",
|
||||
}
|
||||
DEFAULT_USER_AGENT = (
|
||||
DEFAULT_USER_AGENT: str = (
|
||||
"Netflix/2025.2.3.0 "
|
||||
"(DEVTYPE=NFANDROID2-PRV-NVIDIASHIELDANDROIDTV2019; "
|
||||
"Milo=1.0.6315; build_number=6315; build_sha=a1b915de)"
|
||||
)
|
||||
DEFAULT_REQUEST_CONTEXT = '{"appstate":"foreground","reason":"unknown"}'
|
||||
DEFAULT_NRDJS_VERSION = "v3.12.55"
|
||||
DEFAULT_NETJS_VERSION = "3.0.5"
|
||||
DEFAULT_PBO_VERSION = 2
|
||||
DEFAULT_PBO_COMMON = {
|
||||
DEFAULT_REQUEST_CONTEXT: str = '{"appstate":"foreground","reason":"unknown"}'
|
||||
DEFAULT_NRDJS_VERSION: str = "v3.12.55"
|
||||
DEFAULT_NETJS_VERSION: str = "3.0.5"
|
||||
DEFAULT_PBO_VERSION: int = 2
|
||||
DEFAULT_PBO_COMMON: Dict[str, str] = {
|
||||
"sdk": "2025.2.3.0",
|
||||
"platform": "2025.2.3.0",
|
||||
"application": "12.1.9-23083 R 2025.2 android-30-JPLAYER2 ninja_6==NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/7825230_4040.2147:user/release-keys",
|
||||
"uiversion": "UI-release-20260407_44745-gibbon-r100-darwinql-69067=5,80198=2,80211=3",
|
||||
"application": (
|
||||
"12.1.9-23083 R 2025.2 android-30-JPLAYER2 "
|
||||
"ninja_6==NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/"
|
||||
"7825230_4040.2147:user/release-keys"
|
||||
),
|
||||
"uiversion": (
|
||||
"UI-release-20260407_44745-gibbon-r100-darwinql-69067="
|
||||
"5,80198=2,80211=3"
|
||||
),
|
||||
"uiPlatform": "tv_ui",
|
||||
"clientVersion": "v3.12.55",
|
||||
"apkVersion": "12.1.9",
|
||||
}
|
||||
DEFAULT_PBO_LANGUAGES = ["en-US", "en-PH", "en"]
|
||||
DEFAULT_DEVICE_MODEL = "NVIDIA_SHIELD%20Android%20TV"
|
||||
DEFAULT_PBO_LANGUAGES: List[str] = ["en-US", "en-PH", "en"]
|
||||
DEFAULT_DEVICE_MODEL: str = "NVIDIA_SHIELD%20Android%20TV"
|
||||
|
||||
# -- Constructor ---------------------------------------------------------
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -76,14 +92,13 @@ class MSL_TV:
|
||||
sender: str,
|
||||
user_auth: Optional[dict] = None,
|
||||
drm: str = "widevine",
|
||||
):
|
||||
self.session = session
|
||||
self.keys = keys
|
||||
self.sender = sender
|
||||
) -> None:
|
||||
super().__init__(session=session, keys=keys, message_id=message_id, sender=sender)
|
||||
self.user_auth = user_auth
|
||||
self.message_id = message_id
|
||||
self.drm = drm
|
||||
|
||||
# -- Dual-mode handshake (Widevine + RSA) --------------------------------
|
||||
|
||||
@classmethod
|
||||
def handshake(
|
||||
cls,
|
||||
@@ -98,17 +113,21 @@ class MSL_TV:
|
||||
endpoint: Optional[str] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> MSLKeys:
|
||||
"""Perform a key exchange using Widevine (if CDM available) or RSA."""
|
||||
if cookies:
|
||||
session.cookies.update(cookies)
|
||||
|
||||
cache_path = Path(msl_keys_path)
|
||||
msl_keys = MSL_TV.load_cache_data(cache_path)
|
||||
msl_keys = cls.load_cache_data(cache_path)
|
||||
if msl_keys is not None and not new_msl:
|
||||
return msl_keys
|
||||
|
||||
message_id = random.randint(0, pow(2, 52))
|
||||
msl_keys = MSLKeys()
|
||||
|
||||
# ---- Choose DRM scheme ---------------------------------------------
|
||||
if not cdm and drm == "widevine":
|
||||
# No CDM provided – fall back to RSA key exchange
|
||||
msl_keys.rsa = RSA.generate(2048)
|
||||
assert msl_keys.rsa is not None
|
||||
keyrequestdata = {
|
||||
@@ -122,12 +141,15 @@ class MSL_TV:
|
||||
},
|
||||
}
|
||||
elif drm == "widevine":
|
||||
# CDM available – use Widevine
|
||||
if not isinstance(cdm, WidevineCdm):
|
||||
device = WidevineDevice.load(cdm_device)
|
||||
cdm = WidevineCdm.from_device(device)
|
||||
cdm_session = cdm.open()
|
||||
msl_keys.cdm_session = cdm_session
|
||||
challenge = cdm.get_license_challenge(cdm_session, PSSH.new(system_id=PSSH.SystemId.Widevine))
|
||||
challenge = cdm.get_license_challenge(
|
||||
cdm_session, PSSH.new(system_id=PSSH.SystemId.Widevine)
|
||||
)
|
||||
wv_request = base64.b64encode(challenge).decode("utf-8")
|
||||
keyrequestdata = {
|
||||
"scheme": "WIDEVINE",
|
||||
@@ -138,6 +160,7 @@ class MSL_TV:
|
||||
else:
|
||||
raise ValueError(f"Unsupported DRM mode: {drm}")
|
||||
|
||||
# ---- Build & send handshake request --------------------------------
|
||||
data = jsonpickle.encode(
|
||||
{
|
||||
"entityauthdata": {
|
||||
@@ -147,7 +170,7 @@ class MSL_TV:
|
||||
},
|
||||
},
|
||||
"headerdata": base64.standard_b64encode(
|
||||
MSL_TV.generate_msg_header(
|
||||
cls.generate_msg_header(
|
||||
message_id=message_id,
|
||||
sender=sender,
|
||||
is_handshake=True,
|
||||
@@ -181,10 +204,14 @@ class MSL_TV:
|
||||
host="nrdp25.prod.ftl.netflix.com",
|
||||
language="en-US,en-PH,en",
|
||||
)
|
||||
res = session.post(url=handshake_endpoint, data=data, headers=handshake_headers, timeout=30)
|
||||
res = session.post(
|
||||
url=handshake_endpoint, data=data, headers=handshake_headers, timeout=30
|
||||
)
|
||||
|
||||
if res.status_code != 200:
|
||||
raise RuntimeError(f"Key exchange failed: HTTP {res.status_code} {res.text[:500]}")
|
||||
raise RuntimeError(
|
||||
f"Key exchange failed: HTTP {res.status_code} {res.text[:500]}"
|
||||
)
|
||||
|
||||
parsed = cls.parse_concatenated_json(res.text)
|
||||
if not parsed:
|
||||
@@ -193,12 +220,17 @@ class MSL_TV:
|
||||
key_exchange = parsed[0]
|
||||
|
||||
if "errordata" in key_exchange:
|
||||
decoded_error = base64.standard_b64decode(key_exchange["errordata"]).decode("utf-8")
|
||||
decoded_error = base64.standard_b64decode(
|
||||
key_exchange["errordata"]
|
||||
).decode("utf-8")
|
||||
error_json = json.loads(decoded_error)
|
||||
raise RuntimeError(f"Key exchange failed: {error_json}")
|
||||
|
||||
if "headerdata" not in key_exchange:
|
||||
raise RuntimeError(f"Key exchange failed: missing headerdata in response: {str(key_exchange)[:500]}")
|
||||
raise RuntimeError(
|
||||
f"Key exchange failed: missing headerdata in response: "
|
||||
f"{str(key_exchange)[:500]}"
|
||||
)
|
||||
|
||||
header_json = json.loads(
|
||||
base64.standard_b64decode(key_exchange["headerdata"]).decode("utf-8")
|
||||
@@ -206,37 +238,46 @@ class MSL_TV:
|
||||
key_response_data = header_json["keyresponsedata"]
|
||||
key_data = key_response_data["keydata"]
|
||||
|
||||
# ---- Derive encryption / signing keys from response ----------------
|
||||
if cdm:
|
||||
# Widevine path
|
||||
cdm.parse_license(msl_keys.cdm_session, key_data["cdmkeyresponse"])
|
||||
keys = cdm.get_keys(msl_keys.cdm_session)
|
||||
msl_keys.encryption = MSL_TV.get_widevine_key(
|
||||
wv_keys = cdm.get_keys(msl_keys.cdm_session)
|
||||
msl_keys.encryption = get_widevine_key(
|
||||
kid=base64.standard_b64decode(key_data["encryptionkeyid"]),
|
||||
keys=keys,
|
||||
keys=wv_keys,
|
||||
permissions=["AllowEncrypt", "AllowDecrypt"],
|
||||
)
|
||||
msl_keys.sign = MSL_TV.get_widevine_key(
|
||||
msl_keys.sign = get_widevine_key(
|
||||
kid=base64.standard_b64decode(key_data["hmackeyid"]),
|
||||
keys=keys,
|
||||
keys=wv_keys,
|
||||
permissions=["AllowSign", "AllowSignatureVerify"],
|
||||
)
|
||||
else:
|
||||
# RSA path
|
||||
assert msl_keys.rsa is not None
|
||||
cipher_rsa = PKCS1_OAEP.new(msl_keys.rsa)
|
||||
msl_keys.encryption = MSL_TV.base64key_decode(
|
||||
msl_keys.encryption = cls.base64key_decode(
|
||||
json.loads(
|
||||
cipher_rsa.decrypt(base64.standard_b64decode(key_data["encryptionkey"])).decode("utf-8")
|
||||
cipher_rsa.decrypt(
|
||||
base64.standard_b64decode(key_data["encryptionkey"])
|
||||
).decode("utf-8")
|
||||
)["k"]
|
||||
)
|
||||
msl_keys.sign = MSL_TV.base64key_decode(
|
||||
msl_keys.sign = cls.base64key_decode(
|
||||
json.loads(
|
||||
cipher_rsa.decrypt(base64.standard_b64decode(key_data["hmackey"])).decode("utf-8")
|
||||
cipher_rsa.decrypt(
|
||||
base64.standard_b64decode(key_data["hmackey"])
|
||||
).decode("utf-8")
|
||||
)["k"]
|
||||
)
|
||||
|
||||
msl_keys.mastertoken = key_response_data["mastertoken"]
|
||||
MSL_TV.cache_keys(msl_keys, cache_path)
|
||||
cls.cache_keys(msl_keys, cache_path)
|
||||
return msl_keys
|
||||
|
||||
# -- Platform-specific request headers -----------------------------------
|
||||
|
||||
@staticmethod
|
||||
def build_request_headers(
|
||||
request_name: str,
|
||||
@@ -251,6 +292,7 @@ class MSL_TV:
|
||||
language: Optional[str] = "en-US,en-PH,en",
|
||||
device_model: Optional[str] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""Build the HTTP headers required for TV MSL requests."""
|
||||
headers: Dict[str, str] = {
|
||||
"Host": host or "nrdp25.prod.ftl.netflix.com",
|
||||
"Language": language or "en-US,en-PH,en",
|
||||
@@ -285,10 +327,15 @@ class MSL_TV:
|
||||
headers.update(extra_headers)
|
||||
return headers
|
||||
|
||||
# -- Manifest defaults ---------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def manifest_request_defaults() -> Tuple[str, Dict[str, str]]:
|
||||
"""Return the default manifest endpoint and query params for TV."""
|
||||
return MSL_TV.DEFAULT_MANIFEST_ENDPOINT, dict(MSL_TV.DEFAULT_MANIFEST_PARAMS)
|
||||
|
||||
# -- Custom generate_msg_header (TV-specific languages) ------------------
|
||||
|
||||
@staticmethod
|
||||
def generate_msg_header(
|
||||
message_id: int,
|
||||
@@ -298,37 +345,19 @@ class MSL_TV:
|
||||
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": ["en-US", "en-PH", "en"],
|
||||
"encoderformats": ["JSON"],
|
||||
},
|
||||
"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)
|
||||
"""Generate an MSL message header with TV-specific language list."""
|
||||
return MSLBase.generate_msg_header(
|
||||
message_id=message_id,
|
||||
sender=sender,
|
||||
is_handshake=is_handshake,
|
||||
userauthdata=userauthdata,
|
||||
keyrequestdata=keyrequestdata,
|
||||
compression=compression,
|
||||
languages=["en-US", "en-PH", "en"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_widevine_key(kid: bytes, keys: List[Any], permissions: List[str]) -> Optional[bytes]:
|
||||
import re
|
||||
normalized_perms = {re.sub(r'(?<!^)(?=[A-Z])', '_', p).lower() for p in permissions}
|
||||
for key in keys:
|
||||
if key.type != "OPERATOR_SESSION":
|
||||
continue
|
||||
key_perms = {p.lower() for p in (getattr(key, "permissions", None) or [])}
|
||||
if normalized_perms <= key_perms:
|
||||
return key.key
|
||||
return None
|
||||
# -- Override send_message to add PBO normalisation ----------------------
|
||||
# BUG FIX: original used sys.exit(print(...)) on error; now raises RuntimeError.
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
@@ -338,262 +367,46 @@ class MSL_TV:
|
||||
userauthdata: Optional[dict] = None,
|
||||
headers: Optional[dict] = None,
|
||||
proxy: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 30,
|
||||
) -> Tuple[Dict[str, Any], Any]:
|
||||
normalized_application_data = self.normalize_application_data(endpoint, application_data)
|
||||
message = self.create_message(normalized_application_data, userauthdata)
|
||||
request_kwargs: Dict[str, Any] = {
|
||||
"url": endpoint,
|
||||
"data": message,
|
||||
"params": params,
|
||||
"headers": headers,
|
||||
"timeout": 30,
|
||||
}
|
||||
if proxy:
|
||||
request_kwargs["proxies"] = proxy
|
||||
res = self.session.post(**request_kwargs)
|
||||
header, payload_data = self.parse_message(res.text)
|
||||
if "errordata" in header:
|
||||
decoded_error = json.loads(
|
||||
base64.standard_b64decode(header["errordata"].encode("utf-8")).decode("utf-8")
|
||||
)
|
||||
sys.exit(print(f"MSL response contains an error: {decoded_error}"))
|
||||
return header, payload_data
|
||||
"""Send an MSL message with PBO payload normalisation.
|
||||
|
||||
@classmethod
|
||||
def normalize_application_data(cls, endpoint: str, application_data: Any) -> Any:
|
||||
if not isinstance(application_data, dict):
|
||||
return application_data
|
||||
|
||||
if cls._looks_like_wrapped_pbo_payload(application_data):
|
||||
return application_data
|
||||
|
||||
route = cls._extract_pbo_route(application_data, endpoint)
|
||||
if route is None:
|
||||
return application_data
|
||||
|
||||
common = dict(cls.DEFAULT_PBO_COMMON)
|
||||
if isinstance(application_data.get("common"), dict):
|
||||
common.update(application_data["common"])
|
||||
|
||||
wrapped: Dict[str, Any] = {
|
||||
"version": application_data.get("version", cls.DEFAULT_PBO_VERSION),
|
||||
"common": common,
|
||||
"url": route,
|
||||
"languages": application_data.get("languages", list(cls.DEFAULT_PBO_LANGUAGES)),
|
||||
"params": application_data.get("params", {}),
|
||||
}
|
||||
|
||||
for key in ("path", "method", "route", "endpoint"):
|
||||
wrapped.pop(key, None)
|
||||
|
||||
for key, value in application_data.items():
|
||||
if key in wrapped or key in {"version", "common", "languages", "params", "path", "method", "route", "endpoint"}:
|
||||
continue
|
||||
wrapped[key] = value
|
||||
return wrapped
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_wrapped_pbo_payload(application_data: Dict[str, Any]) -> bool:
|
||||
return (
|
||||
"version" in application_data
|
||||
and "common" in application_data
|
||||
and "url" in application_data
|
||||
and "languages" in application_data
|
||||
and "params" in application_data
|
||||
Raises :class:`RuntimeError` on MSL errors instead of calling
|
||||
``sys.exit()``.
|
||||
"""
|
||||
normalized = self.normalize_application_data(endpoint, application_data)
|
||||
return super().send_message(
|
||||
endpoint=endpoint,
|
||||
params=params,
|
||||
application_data=normalized,
|
||||
userauthdata=userauthdata,
|
||||
headers=headers,
|
||||
proxy=proxy,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_pbo_route(application_data: Dict[str, Any], endpoint: str) -> Optional[str]:
|
||||
for key in ("url", "path", "route", "method", "endpoint"):
|
||||
value = application_data.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
continue
|
||||
value = value.strip()
|
||||
return value if value.startswith("/") else f"/{value}"
|
||||
|
||||
endpoint_lower = endpoint.lower()
|
||||
if "manifest" in endpoint_lower:
|
||||
return "/manifest"
|
||||
if "pbo_tokens" in endpoint_lower or "pbo_config" in endpoint_lower:
|
||||
return None
|
||||
return None
|
||||
|
||||
def create_message(self, application_data: Dict[str, 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_application_data = self.gzip_compress(
|
||||
json.dumps(application_data, separators=(",", ":")).encode("utf-8")
|
||||
).decode("utf-8")
|
||||
payload_dicts = [
|
||||
{
|
||||
"sequencenumber": 1,
|
||||
"messageid": self.message_id,
|
||||
"compressionalgo": "GZIP",
|
||||
"data": compressed_application_data,
|
||||
},
|
||||
{
|
||||
"sequencenumber": 2,
|
||||
"messageid": self.message_id,
|
||||
"endofmsg": True,
|
||||
"data": "",
|
||||
},
|
||||
]
|
||||
|
||||
for payload_dict in payload_dicts:
|
||||
payload_chunk = self.encrypt(json.dumps(payload_dict, separators=(",", ":")))
|
||||
message += json.dumps(
|
||||
{
|
||||
"payload": base64.standard_b64encode(payload_chunk.encode("utf-8")).decode("utf-8"),
|
||||
"signature": self.sign(payload_chunk).decode("utf-8"),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return message
|
||||
|
||||
def decrypt_payload_chunks(self, payload_chunks: List[Dict[str, str]]) -> Any:
|
||||
raw_data = ""
|
||||
assert self.keys.encryption is not None
|
||||
|
||||
for payload_chunk in payload_chunks:
|
||||
payload_chunk_json = json.loads(base64.standard_b64decode(payload_chunk["payload"]).decode("utf-8"))
|
||||
payload_decrypted = AES.new(
|
||||
key=self.keys.encryption,
|
||||
mode=AES.MODE_CBC,
|
||||
iv=base64.standard_b64decode(payload_chunk_json["iv"]),
|
||||
).decrypt(base64.standard_b64decode(payload_chunk_json["ciphertext"]))
|
||||
payload_decrypted = Padding.unpad(payload_decrypted, 16)
|
||||
payload_decrypted_json = json.loads(payload_decrypted.decode("utf-8"))
|
||||
|
||||
payload_data = base64.standard_b64decode(payload_decrypted_json["data"])
|
||||
if payload_decrypted_json.get("compressionalgo") == "GZIP":
|
||||
payload_data = zlib.decompress(payload_data, 16 + zlib.MAX_WBITS)
|
||||
raw_data += payload_data.decode("utf-8")
|
||||
|
||||
if not raw_data:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(raw_data)
|
||||
except Exception:
|
||||
return raw_data
|
||||
|
||||
if "error" in data:
|
||||
return None
|
||||
if "result" not in data:
|
||||
return data
|
||||
return data["result"]
|
||||
|
||||
@staticmethod
|
||||
def parse_concatenated_json(message: str) -> List[Dict[str, Any]]:
|
||||
decoder = json.JSONDecoder()
|
||||
items: List[Dict[str, Any]] = []
|
||||
index = 0
|
||||
length = len(message)
|
||||
|
||||
while index < length:
|
||||
while index < length and message[index].isspace():
|
||||
index += 1
|
||||
if index >= length:
|
||||
break
|
||||
item, next_index = decoder.raw_decode(message, index)
|
||||
items.append(item)
|
||||
index = next_index
|
||||
return items
|
||||
|
||||
def parse_message(self, message: str) -> Tuple[Dict[str, Any], Any]:
|
||||
parsed_message = self.parse_concatenated_json(message)
|
||||
header = parsed_message[0]
|
||||
encrypted_payload_chunks = parsed_message[1:] if len(parsed_message) > 1 else []
|
||||
payload_chunks = self.decrypt_payload_chunks(encrypted_payload_chunks) if encrypted_payload_chunks else {}
|
||||
return header, payload_chunks
|
||||
|
||||
@staticmethod
|
||||
def gzip_compress(data: bytes) -> bytes:
|
||||
out = BytesIO()
|
||||
with gzip.GzipFile(fileobj=out, mode="w") as gzip_file:
|
||||
gzip_file.write(data)
|
||||
return base64.standard_b64encode(out.getvalue())
|
||||
|
||||
@staticmethod
|
||||
def base64key_decode(payload: str) -> bytes:
|
||||
length = len(payload) % 4
|
||||
if length == 2:
|
||||
payload += "=="
|
||||
elif length == 3:
|
||||
payload += "="
|
||||
elif length != 0:
|
||||
raise ValueError("Invalid base64 string")
|
||||
return base64.urlsafe_b64decode(payload.encode("utf-8"))
|
||||
|
||||
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"))
|
||||
return json.dumps(
|
||||
{
|
||||
"ciphertext": base64.standard_b64encode(
|
||||
AES.new(self.keys.encryption, AES.MODE_CBC, iv).encrypt(
|
||||
Padding.pad(plaintext.encode("utf-8"), 16)
|
||||
)
|
||||
).decode("utf-8"),
|
||||
"keyid": f"{self.sender}_{tokendata['sequencenumber']}",
|
||||
"sha256": "AA==",
|
||||
"iv": base64.standard_b64encode(iv).decode("utf-8"),
|
||||
}
|
||||
)
|
||||
|
||||
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())
|
||||
# -- Cache I/O overrides (RSA key serialisation) -------------------------
|
||||
|
||||
@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():
|
||||
"""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
|
||||
|
||||
msl_keys = jsonpickle.decode(msl_keys_path.read_text(encoding="utf-8"))
|
||||
if msl_keys.rsa:
|
||||
# Re-import RSA key from serialised PEM form
|
||||
if getattr(msl_keys, "rsa", None):
|
||||
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)
|
||||
remaining_hours = (renewal_window - datetime.now(timezone.utc)).total_seconds() / 3600
|
||||
if remaining_hours < 10:
|
||||
return None
|
||||
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
|
||||
exported_rsa = None
|
||||
if msl_keys.rsa:
|
||||
exported_rsa = msl_keys.rsa.export_key()
|
||||
msl_keys.rsa = exported_rsa
|
||||
with open(msl_keys_path, "w", encoding="utf-8") as cache_file:
|
||||
cache_file.write(jsonpickle.encode(msl_keys, indent=4))
|
||||
msl_keys.rsa = msl_keys.rsa.export_key()
|
||||
MSLBase.cache_keys(msl_keys, msl_keys_path)
|
||||
if original_rsa:
|
||||
msl_keys.rsa = original_rsa
|
||||
msl_keys.rsa = original_rsa
|
||||
|
||||
|
||||
__all__ = ["MSL_TV", "MSLKeys"]
|
||||
|
||||
+111
-186
@@ -1,54 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
import random
|
||||
import zlib
|
||||
import jsonpickle
|
||||
import requests
|
||||
from collections import OrderedDict
|
||||
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
|
||||
|
||||
import jsonpickle
|
||||
import requests
|
||||
from Cryptodome.Cipher import PKCS1_OAEP
|
||||
from Cryptodome.PublicKey import RSA
|
||||
from Cryptodome.PublicKey.RSA import RsaKey
|
||||
from Cryptodome.Random import get_random_bytes
|
||||
from Cryptodome.Util import Padding
|
||||
|
||||
from .msl_base import MSLBase, MSLKeys as _BaseMSLKeys
|
||||
|
||||
|
||||
class MSLObject:
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__} {jsonpickle.encode(self, unpicklable=False)}>"
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform-specific key container
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MSLKeys(_BaseMSLKeys):
|
||||
"""Web MSL keys – extends the base with an RSA key (no CDM session)."""
|
||||
|
||||
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
|
||||
) -> None:
|
||||
super().__init__(encryption=encryption, sign=sign, mastertoken=mastertoken)
|
||||
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 = (
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 = '{"appstate":"foreground"}'
|
||||
DEFAULT_NRDJS_VERSION = "v3.11.512"
|
||||
DEFAULT_NETJS_VERSION = "3.0.5"
|
||||
DEFAULT_REQUEST_CONTEXT: str = '{"appstate":"foreground"}'
|
||||
DEFAULT_NRDJS_VERSION: str = "v3.11.512"
|
||||
DEFAULT_NETJS_VERSION: str = "3.0.5"
|
||||
|
||||
# -- Constructor ---------------------------------------------------------
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -57,12 +65,11 @@ class MSL_WEB:
|
||||
message_id: int,
|
||||
sender: str,
|
||||
user_auth: Optional[dict] = None,
|
||||
):
|
||||
self.session = session
|
||||
self.keys = keys
|
||||
self.sender = sender
|
||||
) -> None:
|
||||
super().__init__(session=session, keys=keys, message_id=message_id, sender=sender)
|
||||
self.user_auth = user_auth
|
||||
self.message_id = message_id
|
||||
|
||||
# -- RSA handshake -------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def handshake(
|
||||
@@ -75,6 +82,7 @@ class MSL_WEB:
|
||||
endpoint: Optional[str] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> MSLKeys:
|
||||
"""Perform an RSA (ASYMMETRIC_WRAPPED) key exchange."""
|
||||
if cookies:
|
||||
session.cookies.update(cookies)
|
||||
|
||||
@@ -140,7 +148,9 @@ class MSL_WEB:
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"Key exchange failed: HTTP {response.status_code} {response.text[:500]}")
|
||||
raise RuntimeError(
|
||||
f"Key exchange failed: HTTP {response.status_code} {response.text[:500]}"
|
||||
)
|
||||
|
||||
parsed = cls.parse_concatenated_json(response.text)
|
||||
if not parsed:
|
||||
@@ -148,26 +158,42 @@ class MSL_WEB:
|
||||
|
||||
header = parsed[0]
|
||||
if "errordata" in header:
|
||||
decoded_error = base64.standard_b64decode(header["errordata"]).decode("utf-8")
|
||||
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]}")
|
||||
raise RuntimeError(
|
||||
f"Key exchange failed: missing headerdata: {str(header)[:500]}"
|
||||
)
|
||||
|
||||
header_json = json.loads(base64.standard_b64decode(header["headerdata"]).decode("utf-8"))
|
||||
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"]
|
||||
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"]
|
||||
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
|
||||
|
||||
# -- Platform-specific request headers -----------------------------------
|
||||
|
||||
@staticmethod
|
||||
def build_request_headers(
|
||||
request_name: str,
|
||||
@@ -179,6 +205,7 @@ class MSL_WEB:
|
||||
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": "*/*",
|
||||
@@ -207,6 +234,8 @@ class MSL_WEB:
|
||||
headers.update(extra_headers)
|
||||
return headers
|
||||
|
||||
# -- Custom generate_msg_header (empty languages / encoderformats) -------
|
||||
|
||||
@staticmethod
|
||||
def generate_msg_header(
|
||||
message_id: int,
|
||||
@@ -216,6 +245,11 @@ class MSL_WEB:
|
||||
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,
|
||||
@@ -236,6 +270,8 @@ class MSL_WEB:
|
||||
header_data["keyrequestdata"] = [keyrequestdata]
|
||||
return jsonpickle.encode(header_data, unpicklable=False)
|
||||
|
||||
# -- Web-specific send_message (no PBO normalisation) --------------------
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
endpoint: str,
|
||||
@@ -244,177 +280,51 @@ class MSL_WEB:
|
||||
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 = 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"))
|
||||
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"))
|
||||
# -- Cache I/O overrides (RSA key serialisation) -------------------------
|
||||
|
||||
@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():
|
||||
"""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
|
||||
|
||||
msl_keys = jsonpickle.decode(msl_keys_path.read_text(encoding="utf-8"))
|
||||
if msl_keys.rsa:
|
||||
# Re-import RSA key from serialised PEM form
|
||||
if getattr(msl_keys, "rsa", None):
|
||||
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:
|
||||
"""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()
|
||||
msl_keys_path.write_text(jsonpickle.encode(msl_keys, indent=4), encoding="utf-8")
|
||||
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(
|
||||
@@ -433,13 +343,17 @@ class MSL_WEB:
|
||||
|
||||
@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]:
|
||||
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")
|
||||
@@ -447,11 +361,19 @@ class MSL_WEB:
|
||||
|
||||
@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")
|
||||
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:
|
||||
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():
|
||||
@@ -473,4 +395,7 @@ class MSL_WEB:
|
||||
secure=item.get("secure", False),
|
||||
expires=item.get("expires"),
|
||||
rest=item.get("rest", {}),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MSL_WEB", "MSLKeys"]
|
||||
|
||||
Reference in New Issue
Block a user