This commit is contained in:
Pari Malam
2026-05-29 17:53:25 +08:00
parent 6680da5adc
commit 2168dbfb93
18 changed files with 7784 additions and 7 deletions
+6
View File
@@ -0,0 +1,6 @@
devices/
config.ini
cookies/*
venv/*
output/*
.vscode/*
+8 -7
View File
@@ -49,8 +49,10 @@ pip install requests pycryptodome pywidevine jsonpickle
├── main.py # Entry point — platform router
├── config.ini # Credentials (EMAIL / PASSWORD)
├── devices/
│ ├── changhong_androidtv_16.0.1@001_f2c92ad9_22594_l3.wvd # L3 Android/iOS WVD
│ └── hisense_smart_tv_14.0.0_af8be6ec_11233_l1.wvd # L1 TV WVD
│ ├── l3.wvd # L3 Android/iOS WVD
│ └── l1.wvd # L1 TV WVD
│ └── KpeKph MGK KpeKph platform: base64 encryption + HMAC keys (comma-separated)
│ └── ESNID # MGK platform: model-group identity string
├── modules/
│ ├── __init__.py
│ ├── config.py # config.ini loader
@@ -58,9 +60,7 @@ pip install requests pycryptodome pywidevine jsonpickle
│ ├── msl_ios.py # MSL_IOS class
│ ├── msl_tv.py # MSL_TV class
│ ├── msl_web.py # MSL_WEB class
│ └── msl_mgk.py # MSL_MGK class (Model Group Key)
├── KpeKph # MGK platform: base64 encryption + HMAC keys (comma-separated)
└── ESNID # MGK platform: model-group identity string
│ └── msl_mgk.py # MSL_MGK class (Model Group Key)
```
---
@@ -85,8 +85,8 @@ Two Widevine Device (`.wvd`) files are included in the `devices/` folder:
| File | Security Level | Used by |
|------|---------------|---------|
| `changhong_androidtv_16.0.1@001_f2c92ad9_22594_l3.wvd` | L3 | Android, iOS |
| `hisense_smart_tv_14.0.0_af8be6ec_11233_l1.wvd` | L1 | TV, TV OTP |
| `l3.wvd` | L3 | Android, iOS |
| `l1.wvd` | L1 | TV, TV OTP |
The correct WVD is selected automatically for each platform. The `--wvd` flag lets you override with a custom device file if needed.
@@ -283,3 +283,4 @@ MSL key caches are reused across runs to avoid a full handshake every time. They
---
**Credits:** [Hugoved](https://github.com/Hugoved)
**Big thanks to Hugov** for the foundational work on MSL (Message Security Layer) reverse engineering, and the original pywidevine implementation that made this unified handshake toolkit possible. This project builds upon years of community research into Netflix's authentication protocols.
+4492
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
from .msl_android import MSL_ANDROID
from .msl_ios import MSL_IOS
from .msl_tv import MSL_TV
from .msl_web import MSL_WEB
from .msl_mgk import MSL_MGK
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
from configparser import ConfigParser
def setup_config(config_file='config.ini'):
config = ConfigParser()
config.read(config_file)
return config
+602
View File
@@ -0,0 +1,602 @@
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
from Cryptodome.Hash import HMAC, SHA256
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)}>"
class MSLKeys(MSLObject):
def __init__(
self,
encryption: Optional[bytes] = None,
sign: Optional[bytes] = None,
rsa: Optional[RsaKey] = None,
mastertoken: Optional[dict] = None,
cdm_session: Any = None,
):
self.encryption = encryption
self.sign = sign
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 = {
"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 = {
"sdk": "18.26.0",
"platform": "18.26.0",
"application": "Netflix Android 18.26.0",
"uiversion": "18.26.0",
"uiPlatform": "android",
"clientVersion": "18.26.0",
"apkVersion": "18.26.0",
}
DEFAULT_PBO_LANGUAGES = ["en-US", "en"]
DEFAULT_DEVICE_MODEL = "SM-F711N"
def __init__(
self,
session: requests.Session,
keys: MSLKeys,
message_id: int,
sender: str,
user_auth: Optional[dict] = None,
drm: str = "widevine",
):
self.session = session
self.keys = keys
self.sender = sender
self.user_auth = user_auth
self.message_id = message_id
self.drm = drm
@classmethod
def handshake(
cls,
msl_keys_path: str,
session: requests.Session,
sender: str,
cdm: Any,
cdm_device: Any,
new_msl: bool,
cookies: Optional[Dict[str, str]],
drm: str,
endpoint: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
) -> MSLKeys:
if cookies:
session.cookies.update(cookies)
cache_path = Path(msl_keys_path)
msl_keys = MSL_ANDROID.load_cache_data(cache_path)
if msl_keys is not None and not new_msl:
return msl_keys
if drm != "widevine":
raise ValueError(f"Unsupported DRM mode: {drm}")
if not cdm:
raise ValueError("Widevine CDM is required for this iOS MSL flow")
message_id = random.randint(0, pow(2, 52))
msl_keys = MSLKeys()
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),
)
wv_request = base64.b64encode(challenge).decode("utf-8")
keyrequestdata = {
"scheme": "WIDEVINE",
"keydata": {
"keyrequest": wv_request,
},
}
data = jsonpickle.encode(
{
"entityauthdata": {
"scheme": "NONE",
"authdata": {
"identity": sender,
},
},
"headerdata": base64.standard_b64encode(
MSL_ANDROID.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"Key exchange failed: HTTP {res.status_code} {res.text[:500]}")
parsed = cls.parse_concatenated_json(res.text)
if not parsed:
raise RuntimeError("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")
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]}")
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"]
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(
kid=base64.standard_b64decode(key_data["encryptionkeyid"]),
keys=keys,
permissions=["AllowEncrypt", "AllowDecrypt"],
)
msl_keys.sign = MSL_ANDROID.get_widevine_key(
kid=base64.standard_b64decode(key_data["hmackeyid"]),
keys=keys,
permissions=["AllowSign", "AllowSignatureVerify"],
)
msl_keys.mastertoken = key_response_data["mastertoken"]
MSL_ANDROID.cache_keys(msl_keys, cache_path)
return msl_keys
@staticmethod
def build_request_headers(
request_name: str,
user_agent: Optional[str] = None,
referer: Optional[str] = None,
viewable_id: Optional[int] = None,
profile_guid: Optional[str] = None,
esn: Optional[str] = None,
expiry_timeout: Optional[int] = 12750,
extra_headers: Optional[Dict[str, str]] = None,
host: Optional[str] = "android15.prod.cloud.netflix.com",
language: Optional[str] = "en-US",
device_model: Optional[str] = None,
) -> Dict[str, str]:
headers: Dict[str, str] = {
"Host": host or "android15.prod.cloud.netflix.com",
"Accept": "*/*",
"User-Agent": user_agent or MSL_ANDROID.DEFAULT_USER_AGENT,
"Accept-Language": language or "en-US",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/json",
"Connection": "keep-alive",
"Content-Encoding": "msl_v1",
"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.client.context": MSL_ANDROID.DEFAULT_REQUEST_CONTEXT,
"x-netflix.request.client.languages": "en-US",
"x-netflix.request.client.timezoneid": "America/New_York",
"x-netflix.clienttype": "samurai",
"x-netflix.deviceformfactor": "PHONE",
"x-netflix.devicememorylevel": "HIGH",
"x-netflix.androidapi": "35",
"x-netflix.context.os-version": "35",
"x-netflix.context.form-factor": "phone",
"x-netflix.context.ui-flavor": "android",
"x-netflix.appver": "9.60.0",
"x-netflix.context.app-version": "9.60.0",
"x-netflix.esnprefix": "NFANDROID1-PRV-P-",
"x-netflix.zuul.brotli.allowed": "true",
"x-netflix.request.client.supportskidstop10": "true",
"x-netflix.request.client.supportsgames": "true",
"x-netflix.request.routing": '{"path":"\\/nq\\/android\\/playback\\/~1.0.0\\/router"}',
"x-netflix.context.locales": "en-US",
"x-netflix.context.android.installer-source": "com.android.vending",
}
if referer:
headers["Referer"] = referer
if viewable_id is not None:
headers["x-netflix.playback.main-content-viewable-id"] = str(viewable_id)
if profile_guid:
headers["x-netflix.client.current-profile-guid"] = profile_guid
if esn:
headers["x-netflix.client.ftl.esn"] = esn
headers["x-netflix.esn"] = esn
if expiry_timeout is not None:
headers["x-netflix.request.expiry.timeout"] = str(expiry_timeout)
if extra_headers:
headers.update(extra_headers)
return headers
@staticmethod
def manifest_request_defaults() -> Tuple[str, Dict[str, str]]:
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
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,
) -> 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
)
@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))
+566
View File
@@ -0,0 +1,566 @@
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
from Cryptodome.Hash import HMAC, SHA256
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)}>"
class MSLKeys(MSLObject):
def __init__(
self,
encryption: Optional[bytes] = None,
sign: Optional[bytes] = None,
rsa: Optional[RsaKey] = None,
mastertoken: Optional[dict] = None,
cdm_session: Any = None,
):
self.encryption = encryption
self.sign = sign
self.rsa = rsa
self.mastertoken = mastertoken
self.cdm_session = cdm_session
class MSL_IOS:
DEFAULT_HANDSHAKE_ENDPOINT = "https://ios.prod.ftl.netflix.com/nq/iosplatform/pbo_license/~1.0.0/router"
DEFAULT_MANIFEST_ENDPOINT = "https://ios.prod.ftl.netflix.com/msl/playapi/ios/manifest"
DEFAULT_MANIFEST_PARAMS = {
"ab_ui_ver": "darwin",
"nrdapp_version": "18.26.0",
}
DEFAULT_USER_AGENT = "Netflix/5850 CFNetwork/3826.600.41 Darwin/24.6.0"
DEFAULT_REQUEST_CONTEXT = '{"appView":"login","appState":"foreground"}'
DEFAULT_NRDJS_VERSION = "v3.12.55"
DEFAULT_NETJS_VERSION = "3.0.5"
DEFAULT_PBO_VERSION = 2
DEFAULT_PBO_COMMON = {
"sdk": "18.26.0",
"platform": "18.26.0",
"application": "Netflix iOS 18.26.0",
"uiversion": "18.26.0",
"uiPlatform": "ios",
"clientVersion": "18.26.0",
"apkVersion": "18.26.0",
}
DEFAULT_PBO_LANGUAGES = ["en-US", "en"]
DEFAULT_DEVICE_MODEL = "iPhone15%2C3"
def __init__(
self,
session: requests.Session,
keys: MSLKeys,
message_id: int,
sender: str,
user_auth: Optional[dict] = None,
drm: str = "widevine",
):
self.session = session
self.keys = keys
self.sender = sender
self.user_auth = user_auth
self.message_id = message_id
self.drm = drm
@classmethod
def handshake(
cls,
msl_keys_path: str,
session: requests.Session,
sender: str,
cdm: Any,
cdm_device: Any,
new_msl: bool,
cookies: Optional[Dict[str, str]],
drm: str,
endpoint: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
) -> MSLKeys:
if cookies:
session.cookies.update(cookies)
cache_path = Path(msl_keys_path)
msl_keys = MSL_IOS.load_cache_data(cache_path)
if msl_keys is not None and not new_msl:
return msl_keys
if drm != "widevine":
raise ValueError(f"Unsupported DRM mode: {drm}")
if not cdm:
raise ValueError("Widevine CDM is required for this iOS MSL flow")
message_id = random.randint(0, pow(2, 52))
msl_keys = MSLKeys()
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),
)
wv_request = base64.b64encode(challenge).decode("utf-8")
keyrequestdata = {
"scheme": "WIDEVINE",
"keydata": {
"keyrequest": wv_request,
},
}
data = jsonpickle.encode(
{
"entityauthdata": {
"scheme": "NONE",
"authdata": {
"identity": sender,
},
},
"headerdata": base64.standard_b64encode(
MSL_IOS.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="ios.prod.ftl.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"Key exchange failed: HTTP {res.status_code} {res.text[:500]}")
parsed = cls.parse_concatenated_json(res.text)
if not parsed:
raise RuntimeError("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")
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]}")
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"]
cdm.parse_license(msl_keys.cdm_session, key_data["cdmkeyresponse"])
keys = cdm.get_keys(msl_keys.cdm_session)
msl_keys.encryption = MSL_IOS.get_widevine_key(
kid=base64.standard_b64decode(key_data["encryptionkeyid"]),
keys=keys,
permissions=["AllowEncrypt", "AllowDecrypt"],
)
msl_keys.sign = MSL_IOS.get_widevine_key(
kid=base64.standard_b64decode(key_data["hmackeyid"]),
keys=keys,
permissions=["AllowSign", "AllowSignatureVerify"],
)
msl_keys.mastertoken = key_response_data["mastertoken"]
MSL_IOS.cache_keys(msl_keys, cache_path)
return msl_keys
@staticmethod
def build_request_headers(
request_name: str,
user_agent: Optional[str] = None,
referer: Optional[str] = None,
viewable_id: Optional[int] = None,
profile_guid: Optional[str] = None,
esn: Optional[str] = None,
expiry_timeout: Optional[int] = 12750,
extra_headers: Optional[Dict[str, str]] = None,
host: Optional[str] = "ios.prod.ftl.netflix.com",
language: Optional[str] = "en-US,en",
device_model: Optional[str] = None,
) -> Dict[str, str]:
headers: Dict[str, str] = {
"Host": host or "ios.prod.ftl.netflix.com",
"Accept": "*/*",
"User-Agent": user_agent or MSL_IOS.DEFAULT_USER_AGENT,
"Accept-Language": language or "en-US,en",
"Accept-Encoding": "deflate,gzip",
"Content-Type": "application/json",
"Connection": "keep-alive",
"Content-Encoding": "msl_v1",
"X-Gibbon-Cache-Control": "no-cache",
"X-AllowCompression": "true",
"X-Client-Request-Id": str(random.randint(10**17, 10**18 - 1)),
"X-DeviceModel": device_model or MSL_IOS.DEFAULT_DEVICE_MODEL,
"X-Netflix.Client.Request.Name": request_name,
"X-Netflix.Request.Attempt": "1",
"X-Netflix.Request.NonJson.Headers": "true",
"X-Netflix.Request.Client.Context": MSL_IOS.DEFAULT_REQUEST_CONTEXT,
"X-Netflix.request.expiry.timeout": str(expiry_timeout if expiry_timeout is not None else 12750),
"x-netflix.client.nrdjs.version": MSL_IOS.DEFAULT_NRDJS_VERSION,
"x-netflix.client.netjs.version": MSL_IOS.DEFAULT_NETJS_VERSION,
"x-netflix.client.last-interacted-days": "0",
}
if referer:
headers["Referer"] = referer
if viewable_id is not None:
headers["x-netflix.playback.main-content-viewable-id"] = str(viewable_id)
if profile_guid:
headers["x-netflix.client.current-profile-guid"] = profile_guid
if esn:
headers["x-netflix.client.ftl.esn"] = esn
headers["x-netflix.esn"] = esn
if extra_headers:
headers.update(extra_headers)
return headers
@staticmethod
def manifest_request_defaults() -> Tuple[str, Dict[str, str]]:
return MSL_IOS.DEFAULT_MANIFEST_ENDPOINT, dict(MSL_IOS.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
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,
) -> 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
@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
)
@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))
+1008
View File
File diff suppressed because it is too large Load Diff
+599
View File
@@ -0,0 +1,599 @@
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
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)}>"
class MSLKeys(MSLObject):
def __init__(
self,
encryption: Optional[bytes] = None,
sign: Optional[bytes] = None,
rsa: Optional[RsaKey] = None,
mastertoken: Optional[dict] = None,
cdm_session: Any = None,
):
self.encryption = encryption
self.sign = sign
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 = {
"ab_ui_ver": "darwin",
"nrdapp_version": "2025.2.3.0",
}
DEFAULT_USER_AGENT = (
"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 = {
"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",
"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"
def __init__(
self,
session: requests.Session,
keys: MSLKeys,
message_id: int,
sender: str,
user_auth: Optional[dict] = None,
drm: str = "widevine",
):
self.session = session
self.keys = keys
self.sender = sender
self.user_auth = user_auth
self.message_id = message_id
self.drm = drm
@classmethod
def handshake(
cls,
msl_keys_path: str,
session: requests.Session,
sender: str,
cdm: Any,
cdm_device: Any,
new_msl: bool,
cookies: Optional[Dict[str, str]],
drm: str,
endpoint: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
) -> MSLKeys:
if cookies:
session.cookies.update(cookies)
cache_path = Path(msl_keys_path)
msl_keys = MSL_TV.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()
if not cdm and drm == "widevine":
msl_keys.rsa = RSA.generate(2048)
assert msl_keys.rsa is not None
keyrequestdata = {
"scheme": "ASYMMETRIC_WRAPPED",
"keydata": {
"keypairid": "rsaKeypairId",
"mechanism": "JWK_RSA",
"publickey": base64.b64encode(
msl_keys.rsa.publickey().export_key(format="DER")
).decode("utf-8"),
},
}
elif drm == "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))
wv_request = base64.b64encode(challenge).decode("utf-8")
keyrequestdata = {
"scheme": "WIDEVINE",
"keydata": {
"keyrequest": wv_request,
},
}
else:
raise ValueError(f"Unsupported DRM mode: {drm}")
data = jsonpickle.encode(
{
"entityauthdata": {
"scheme": "NONE",
"authdata": {
"identity": sender,
},
},
"headerdata": base64.standard_b64encode(
MSL_TV.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="nrdp25.prod.ftl.netflix.com",
language="en-US,en-PH,en",
)
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]}")
parsed = cls.parse_concatenated_json(res.text)
if not parsed:
raise RuntimeError("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")
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]}")
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"]
if cdm:
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(
kid=base64.standard_b64decode(key_data["encryptionkeyid"]),
keys=keys,
permissions=["AllowEncrypt", "AllowDecrypt"],
)
msl_keys.sign = MSL_TV.get_widevine_key(
kid=base64.standard_b64decode(key_data["hmackeyid"]),
keys=keys,
permissions=["AllowSign", "AllowSignatureVerify"],
)
else:
assert msl_keys.rsa is not None
cipher_rsa = PKCS1_OAEP.new(msl_keys.rsa)
msl_keys.encryption = MSL_TV.base64key_decode(
json.loads(
cipher_rsa.decrypt(base64.standard_b64decode(key_data["encryptionkey"])).decode("utf-8")
)["k"]
)
msl_keys.sign = MSL_TV.base64key_decode(
json.loads(
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)
return msl_keys
@staticmethod
def build_request_headers(
request_name: str,
user_agent: Optional[str] = None,
referer: Optional[str] = None,
viewable_id: Optional[int] = None,
profile_guid: Optional[str] = None,
esn: Optional[str] = None,
expiry_timeout: Optional[int] = 12750,
extra_headers: Optional[Dict[str, str]] = None,
host: Optional[str] = "nrdp25.prod.ftl.netflix.com",
language: Optional[str] = "en-US,en-PH,en",
device_model: Optional[str] = None,
) -> Dict[str, str]:
headers: Dict[str, str] = {
"Host": host or "nrdp25.prod.ftl.netflix.com",
"Language": language or "en-US,en-PH,en",
"User-Agent": user_agent or MSL_TV.DEFAULT_USER_AGENT,
"Accept": "*/*",
"Connection": "Keep-Alive",
"Accept-Encoding": "deflate,gzip",
"Content-Type": "application/json",
"X-Gibbon-Cache-Control": "no-cache",
"X-AllowCompression": "true",
"X-Client-Request-Id": str(random.randint(10**17, 10**18 - 1)),
"X-DeviceModel": device_model or MSL_TV.DEFAULT_DEVICE_MODEL,
"x-netflix.client.nrdjs.version": MSL_TV.DEFAULT_NRDJS_VERSION,
"X-Netflix.Client.Request.Name": request_name,
"X-Netflix.request.attempt": "1",
"X-Netflix.Request.NonJson.Headers": "true",
"X-Netflix.Request.Client.Context": MSL_TV.DEFAULT_REQUEST_CONTEXT,
"x-netflix.client.netjs.version": MSL_TV.DEFAULT_NETJS_VERSION,
"x-netflix.client.last-interacted-days": "0",
}
if expiry_timeout is not None:
headers["x-netflix.request.expiry.timeout"] = str(expiry_timeout)
if referer:
headers["Referer"] = referer
if viewable_id is not None:
headers["x-netflix.playback.main-content-viewable-id"] = str(viewable_id)
if profile_guid:
headers["x-netflix.client.current-profile-guid"] = profile_guid
if esn:
headers["x-netflix.client.ftl.esn"] = esn
if extra_headers:
headers.update(extra_headers)
return headers
@staticmethod
def manifest_request_defaults() -> Tuple[str, Dict[str, str]]:
return MSL_TV.DEFAULT_MANIFEST_ENDPOINT, dict(MSL_TV.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-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)
@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
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,
) -> 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
@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
)
@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.rsa:
msl_keys.rsa = RSA.import_key(msl_keys.rsa)
if msl_keys.mastertoken:
tokendata = json.loads(base64.standard_b64decode(msl_keys.mastertoken["tokendata"]).decode("utf-8"))
renewal_window = datetime.fromtimestamp(int(tokendata["renewalwindow"]), tz=timezone.utc)
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:
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))
if original_rsa:
msl_keys.rsa = original_rsa
+476
View File
@@ -0,0 +1,476 @@
from __future__ import annotations
import base64
import gzip
import json
import random
import zlib
import jsonpickle
import requests
from datetime import datetime, timezone
from http.cookiejar import CookieJar
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
from collections import OrderedDict
from Cryptodome.Cipher import AES, PKCS1_OAEP
from Cryptodome.Hash import HMAC, SHA256
from Cryptodome.PublicKey import RSA
from Cryptodome.PublicKey.RSA import RsaKey
from Cryptodome.Random import get_random_bytes
from Cryptodome.Util import Padding
class MSLObject:
def __repr__(self) -> str:
return f"<{self.__class__.__name__} {jsonpickle.encode(self, unpicklable=False)}>"
class MSLKeys(MSLObject):
def __init__(
self,
encryption: Optional[bytes] = None,
sign: Optional[bytes] = None,
rsa: Optional[RsaKey] = None,
mastertoken: Optional[dict] = None,
):
self.encryption = encryption
self.sign = sign
self.rsa = rsa
self.mastertoken = mastertoken
class MSL_WEB:
DEFAULT_HANDSHAKE_ENDPOINT = "https://www.netflix.com/nq/msl_v1/nrdjs/pbo_tokens/%5E1.0.0/router"
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/146.0.0.0 Safari/537.36"
)
DEFAULT_REQUEST_CONTEXT = '{"appstate":"foreground"}'
DEFAULT_NRDJS_VERSION = "v3.11.512"
DEFAULT_NETJS_VERSION = "3.0.5"
def __init__(
self,
session: requests.Session,
keys: MSLKeys,
message_id: int,
sender: str,
user_auth: Optional[dict] = None,
):
self.session = session
self.keys = keys
self.sender = sender
self.user_auth = user_auth
self.message_id = message_id
@classmethod
def handshake(
cls,
msl_keys_path: str | Path,
session: requests.Session,
sender: str,
new_msl: bool = False,
cookies: Optional[Dict[str, str]] = None,
endpoint: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
) -> MSLKeys:
if cookies:
session.cookies.update(cookies)
cache_path = Path(msl_keys_path)
cached = cls.load_cache_data(cache_path)
if cached is not None and not new_msl:
return cached
message_id = random.randint(0, 2**52)
keys = MSLKeys()
keys.rsa = RSA.generate(2048)
keyrequestdata = {
"scheme": "ASYMMETRIC_WRAPPED",
"keydata": {
"keypairid": "rsaKeypairId",
"mechanism": "JWK_RSA",
"publickey": base64.b64encode(
keys.rsa.publickey().export_key(format="DER")
).decode("utf-8"),
},
}
envelope = jsonpickle.encode(
{
"entityauthdata": {
"scheme": "NONE",
"authdata": {"identity": sender},
},
"headerdata": base64.standard_b64encode(
cls.generate_msg_header(
message_id=message_id,
sender=sender,
is_handshake=True,
keyrequestdata=keyrequestdata,
).encode("utf-8")
).decode("utf-8"),
"signature": "",
},
unpicklable=False,
)
envelope += json.dumps(
{
"payload": base64.standard_b64encode(
json.dumps(
{
"messageid": message_id,
"data": "",
"sequencenumber": 1,
"endofmsg": True,
}
).encode("utf-8")
).decode("utf-8"),
"signature": "",
},
separators=(",", ":"),
)
response = session.post(
url=endpoint or cls.DEFAULT_HANDSHAKE_ENDPOINT,
data=envelope,
headers=headers or cls.build_request_headers(request_name="aleProvision"),
timeout=30,
)
if response.status_code != 200:
raise RuntimeError(f"Key exchange failed: HTTP {response.status_code} {response.text[:500]}")
parsed = cls.parse_concatenated_json(response.text)
if not parsed:
raise RuntimeError("Key exchange failed: empty MSL response")
header = parsed[0]
if "errordata" in header:
decoded_error = base64.standard_b64decode(header["errordata"]).decode("utf-8")
raise RuntimeError(f"Key exchange failed: {decoded_error}")
if "headerdata" not in header:
raise RuntimeError(f"Key exchange failed: missing headerdata: {str(header)[:500]}")
header_json = json.loads(base64.standard_b64decode(header["headerdata"]).decode("utf-8"))
key_data = header_json["keyresponsedata"]["keydata"]
cipher_rsa = PKCS1_OAEP.new(keys.rsa)
keys.encryption = cls.base64key_decode(
json.loads(cipher_rsa.decrypt(base64.standard_b64decode(key_data["encryptionkey"])).decode("utf-8"))["k"]
)
keys.sign = cls.base64key_decode(
json.loads(cipher_rsa.decrypt(base64.standard_b64decode(key_data["hmackey"])).decode("utf-8"))["k"]
)
keys.mastertoken = header_json["keyresponsedata"]["mastertoken"]
cls.cache_keys(keys, cache_path)
return keys
@staticmethod
def build_request_headers(
request_name: str,
user_agent: Optional[str] = None,
referer: Optional[str] = None,
viewable_id: Optional[int] = None,
profile_guid: Optional[str] = None,
esn: Optional[str] = None,
expiry_timeout: Optional[int] = 12750,
extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, str]:
headers: Dict[str, str] = {
"User-Agent": user_agent or MSL_WEB.DEFAULT_USER_AGENT,
"Accept": "*/*",
"Content-Type": "application/json",
"Content-Encoding": "msl_v1",
"Origin": "https://www.netflix.com",
"X-Netflix.Client.Request.Name": request_name,
"X-Netflix.request.attempt": "1",
"X-Netflix.Request.NonJson.Headers": "true",
"X-Netflix.Request.Client.Context": MSL_WEB.DEFAULT_REQUEST_CONTEXT,
"x-netflix.client.nrdjs.version": MSL_WEB.DEFAULT_NRDJS_VERSION,
"x-netflix.client.netjs.version": MSL_WEB.DEFAULT_NETJS_VERSION,
"x-netflix.client.last-interacted-days": "0",
}
if expiry_timeout is not None:
headers["x-netflix.request.expiry.timeout"] = str(expiry_timeout)
if referer:
headers["Referer"] = referer
if viewable_id is not None:
headers["x-netflix.playback.main-content-viewable-id"] = str(viewable_id)
if profile_guid:
headers["x-netflix.client.current-profile-guid"] = profile_guid
if esn:
headers["x-netflix.client.ftl.esn"] = esn
if extra_headers:
headers.update(extra_headers)
return headers
@staticmethod
def generate_msg_header(
message_id: int,
sender: str,
is_handshake: bool,
userauthdata: Optional[dict] = None,
keyrequestdata: Optional[dict] = None,
compression: Optional[str] = "GZIP",
) -> str:
header_data: Dict[str, Any] = {
"messageid": message_id,
"renewable": True,
"handshake": is_handshake,
"capabilities": {
"compressionalgos": [compression] if compression else [],
"languages": [],
"encoderformats": [],
},
"timestamp": int(datetime.now(timezone.utc).timestamp()),
"sender": sender,
"nonreplayable": False,
"recipient": "Netflix",
}
if userauthdata:
header_data["userauthdata"] = userauthdata
if keyrequestdata:
header_data["keyrequestdata"] = [keyrequestdata]
return jsonpickle.encode(header_data, unpicklable=False)
def send_message(
self,
endpoint: str,
params: Dict[str, str],
application_data: Any,
userauthdata: Optional[dict] = None,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[Dict[str, Any], Any]:
message = self.create_message(application_data, userauthdata)
response = self.session.post(url=endpoint, params=params, data=message, headers=headers, timeout=30)
response.raise_for_status()
header, payload = self.parse_message(response.text)
if "errordata" in header:
decoded_error = json.loads(base64.standard_b64decode(header["errordata"]).decode("utf-8"))
raise RuntimeError(f"MSL response contains an error: {decoded_error}")
return header, payload
def create_message(self, application_data: Any, userauthdata: Optional[dict] = None) -> str:
self.message_id += 1
headerdata = self.encrypt(
self.generate_msg_header(
message_id=self.message_id,
sender=self.sender,
is_handshake=False,
userauthdata=userauthdata,
)
)
message = json.dumps(
{
"headerdata": base64.standard_b64encode(headerdata.encode("utf-8")).decode("utf-8"),
"signature": self.sign(headerdata).decode("utf-8"),
"mastertoken": self.keys.mastertoken,
},
separators=(",", ":"),
)
compressed_data = self.gzip_compress(json.dumps(application_data, separators=(",", ":")).encode("utf-8")).decode("utf-8")
payloads = [
{
"sequencenumber": 1,
"messageid": self.message_id,
"compressionalgo": "GZIP",
"data": compressed_data,
},
{
"sequencenumber": 2,
"messageid": self.message_id,
"endofmsg": True,
"data": "",
},
]
for payload in payloads:
encrypted_chunk = self.encrypt(json.dumps(payload, separators=(",", ":")))
message += json.dumps(
{
"payload": base64.standard_b64encode(encrypted_chunk.encode("utf-8")).decode("utf-8"),
"signature": self.sign(encrypted_chunk).decode("utf-8"),
},
separators=(",", ":"),
)
return message
def parse_message(self, message: str) -> Tuple[Dict[str, Any], Any]:
parsed = self.parse_concatenated_json(message)
header = parsed[0]
payload_chunks = parsed[1:] if len(parsed) > 1 else []
payload = self.decrypt_payload_chunks(payload_chunks) if payload_chunks else {}
return header, payload
def decrypt_payload_chunks(self, payload_chunks: List[Dict[str, str]]) -> Any:
if not self.keys.encryption:
raise ValueError("Encryption key is not available")
raw_data = ""
for payload_chunk in payload_chunks:
chunk_json = json.loads(base64.standard_b64decode(payload_chunk["payload"]).decode("utf-8"))
decrypted = AES.new(
key=self.keys.encryption,
mode=AES.MODE_CBC,
iv=base64.standard_b64decode(chunk_json["iv"]),
).decrypt(base64.standard_b64decode(chunk_json["ciphertext"]))
decrypted = Padding.unpad(decrypted, 16)
payload_json = json.loads(decrypted.decode("utf-8"))
payload_data = base64.standard_b64decode(payload_json["data"])
if payload_json.get("compressionalgo") == "GZIP":
payload_data = zlib.decompress(payload_data, 16 + zlib.MAX_WBITS)
raw_data += payload_data.decode("utf-8")
data = json.loads(raw_data)
if "error" in data:
return None
return data.get("result", data)
def encrypt(self, plaintext: str) -> str:
if not self.keys.encryption:
raise ValueError("Encryption key is not available")
if not self.keys.mastertoken:
raise ValueError("Master token is not available")
iv = get_random_bytes(16)
tokendata = json.loads(base64.standard_b64decode(self.keys.mastertoken["tokendata"]).decode("utf-8"))
ciphertext = AES.new(self.keys.encryption, AES.MODE_CBC, iv).encrypt(Padding.pad(plaintext.encode("utf-8"), 16))
return json.dumps(
{
"ciphertext": base64.standard_b64encode(ciphertext).decode("utf-8"),
"keyid": f"{self.sender}_{tokendata['sequencenumber']}",
"sha256": "AA==",
"iv": base64.standard_b64encode(iv).decode("utf-8"),
},
separators=(",", ":"),
)
def sign(self, text: str) -> bytes:
if not self.keys.sign:
raise ValueError("Sign key is not available")
return base64.standard_b64encode(HMAC.new(self.keys.sign, text.encode("utf-8"), SHA256).digest())
@staticmethod
def parse_concatenated_json(message: str) -> List[Dict[str, Any]]:
decoder = json.JSONDecoder()
items: List[Dict[str, Any]] = []
index = 0
while index < len(message):
while index < len(message) and message[index].isspace():
index += 1
if index >= len(message):
break
item, index = decoder.raw_decode(message, index)
items.append(item)
return items
@staticmethod
def gzip_compress(data: bytes) -> bytes:
out = BytesIO()
with gzip.GzipFile(fileobj=out, mode="w") as handle:
handle.write(data)
return base64.standard_b64encode(out.getvalue())
@staticmethod
def base64key_decode(payload: str) -> bytes:
remainder = len(payload) % 4
if remainder == 2:
payload += "=="
elif remainder == 3:
payload += "="
elif remainder != 0:
raise ValueError("Invalid base64 string")
return base64.urlsafe_b64decode(payload.encode("utf-8"))
@staticmethod
def load_cache_data(msl_keys_path: Optional[Path] = None) -> Optional[MSLKeys]:
if not msl_keys_path or not msl_keys_path.is_file():
return None
msl_keys = jsonpickle.decode(msl_keys_path.read_text(encoding="utf-8"))
if msl_keys.rsa:
msl_keys.rsa = RSA.import_key(msl_keys.rsa)
if msl_keys.mastertoken:
tokendata = json.loads(base64.standard_b64decode(msl_keys.mastertoken["tokendata"]).decode("utf-8"))
renewal_window = datetime.fromtimestamp(int(tokendata["renewalwindow"]), tz=timezone.utc)
if (renewal_window - datetime.now(timezone.utc)).total_seconds() / 3600 < 10:
return None
return msl_keys
@staticmethod
def cache_keys(msl_keys: MSLKeys, msl_keys_path: Path) -> None:
original_rsa = msl_keys.rsa
if msl_keys.rsa:
msl_keys.rsa = msl_keys.rsa.export_key()
msl_keys_path.write_text(jsonpickle.encode(msl_keys, indent=4), encoding="utf-8")
if original_rsa:
msl_keys.rsa = original_rsa
@staticmethod
def cookiejar_to_list(cookie_jar: CookieJar) -> List[Dict[str, Any]]:
cookies: List[Dict[str, Any]] = []
for cookie in cookie_jar:
cookies.append(
{
"name": cookie.name,
"value": cookie.value,
"domain": cookie.domain,
"path": cookie.path,
"secure": bool(cookie.secure),
"expires": cookie.expires,
"discard": bool(cookie.discard),
"rest": dict(cookie._rest),
}
)
return cookies
@staticmethod
def cookiejar_to_ordered_dict(cookie_jar: CookieJar) -> Dict[str, str]:
cookies: Dict[str, str] = OrderedDict()
for cookie in cookie_jar:
cookies[cookie.name] = cookie.value
return cookies
@staticmethod
def save_cookie_values(cookie_jar: CookieJar, output_path: str | Path) -> Dict[str, str]:
path = Path(output_path)
payload = MSL_WEB.cookiejar_to_ordered_dict(cookie_jar)
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return payload
@staticmethod
def save_cookiejar(cookie_jar: CookieJar, output_path: str | Path) -> None:
path = Path(output_path)
path.write_text(json.dumps(MSL_WEB.cookiejar_to_list(cookie_jar), indent=2), encoding="utf-8")
@staticmethod
def load_cookiejar(session: requests.Session, source: str | Path | Iterable[Dict[str, Any]] | Dict[str, str]) -> None:
if isinstance(source, (str, Path)):
path = Path(source)
if not path.is_file():
return
payload = json.loads(path.read_text(encoding="utf-8"))
else:
payload = source
if isinstance(payload, dict):
session.cookies.update(payload)
return
for item in payload:
session.cookies.set(
name=item["name"],
value=item["value"],
domain=item.get("domain"),
path=item.get("path", "/"),
secure=item.get("secure", False),
expires=item.get("expires"),
rest=item.get("rest", {}),
)
+16
View File
@@ -0,0 +1,16 @@
certifi==2026.5.20
charset-normalizer==3.4.7
click==8.4.1
colorama==0.4.6
construct==2.8.8
idna==3.17
jsonpickle==4.1.2
protobuf==6.33.6
pycryptodome==3.23.0
pycryptodomex==3.23.0
pymp4==1.4.0
pywidevine==1.9.0
PyYAML==6.0.3
requests==2.34.2
Unidecode==1.4.0
urllib3==2.7.0