2026-05-29 20:53:59 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-05-29 17:53:25 +08:00
|
|
|
|
import base64
|
|
|
|
|
|
import json
|
2026-05-30 10:52:45 +08:00
|
|
|
|
import logging
|
2026-05-29 17:53:25 +08:00
|
|
|
|
import random
|
|
|
|
|
|
from pathlib import Path
|
2026-05-30 10:52:45 +08:00
|
|
|
|
|
|
|
|
|
|
_log = logging.getLogger(__name__)
|
2026-05-29 17:53:25 +08:00
|
|
|
|
from typing import Any, Dict, List, Optional, Tuple
|
2026-05-29 20:53:59 +08:00
|
|
|
|
|
|
|
|
|
|
import jsonpickle
|
|
|
|
|
|
import requests
|
|
|
|
|
|
from Cryptodome.Cipher import PKCS1_OAEP, AES as _AES
|
|
|
|
|
|
from Cryptodome.Hash import SHA1
|
|
|
|
|
|
from Cryptodome.PublicKey import RSA
|
2026-05-29 17:53:25 +08:00
|
|
|
|
from Cryptodome.PublicKey.RSA import RsaKey
|
|
|
|
|
|
from pywidevine import Cdm as WidevineCdm, Device as WidevineDevice, PSSH
|
|
|
|
|
|
|
2026-05-30 10:52:45 +08:00
|
|
|
|
from modules.msl.base import MSLBase, MSLKeys as _BaseMSLKeys, get_widevine_key
|
2026-05-29 17:53:25 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-29 20:53:59 +08:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Platform-specific key container
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
class MSLKeys(_BaseMSLKeys):
|
|
|
|
|
|
"""Android MSL keys – extends the base with an RSA key and CDM session."""
|
|
|
|
|
|
|
2026-05-29 17:53:25 +08:00
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
encryption: Optional[bytes] = None,
|
|
|
|
|
|
sign: Optional[bytes] = None,
|
|
|
|
|
|
rsa: Optional[RsaKey] = None,
|
|
|
|
|
|
mastertoken: Optional[dict] = None,
|
|
|
|
|
|
cdm_session: Any = None,
|
2026-05-29 20:53:59 +08:00
|
|
|
|
) -> None:
|
|
|
|
|
|
super().__init__(encryption=encryption, sign=sign, mastertoken=mastertoken)
|
2026-05-29 17:53:25 +08:00
|
|
|
|
self.rsa = rsa
|
|
|
|
|
|
self.cdm_session = cdm_session
|
|
|
|
|
|
|
2026-05-29 20:53:59 +08:00
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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] = {
|
2026-05-29 17:53:25 +08:00
|
|
|
|
"ab_ui_ver": "android",
|
|
|
|
|
|
"nrdapp_version": "18.26.0",
|
|
|
|
|
|
}
|
2026-05-29 20:53:59 +08:00
|
|
|
|
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] = {
|
2026-05-29 17:53:25 +08:00
|
|
|
|
"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",
|
|
|
|
|
|
}
|
2026-05-29 20:53:59 +08:00
|
|
|
|
DEFAULT_PBO_LANGUAGES: List[str] = ["en-US", "en"]
|
|
|
|
|
|
DEFAULT_DEVICE_MODEL: str = "SM-F711N"
|
|
|
|
|
|
|
|
|
|
|
|
# -- Constructor ---------------------------------------------------------
|
2026-05-29 17:53:25 +08:00
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
session: requests.Session,
|
|
|
|
|
|
keys: MSLKeys,
|
|
|
|
|
|
message_id: int,
|
|
|
|
|
|
sender: str,
|
|
|
|
|
|
user_auth: Optional[dict] = None,
|
|
|
|
|
|
drm: str = "widevine",
|
2026-05-30 10:52:45 +08:00
|
|
|
|
proxy: Optional[Dict[str, str]] = None,
|
2026-05-29 20:53:59 +08:00
|
|
|
|
) -> None:
|
2026-05-30 10:52:45 +08:00
|
|
|
|
super().__init__(session=session, keys=keys, message_id=message_id, sender=sender, proxy=proxy)
|
2026-05-29 17:53:25 +08:00
|
|
|
|
self.user_auth = user_auth
|
|
|
|
|
|
self.drm = drm
|
|
|
|
|
|
|
2026-05-29 20:53:59 +08:00
|
|
|
|
# -- Widevine handshake --------------------------------------------------
|
|
|
|
|
|
|
2026-05-29 17:53:25 +08:00
|
|
|
|
@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:
|
2026-05-29 20:53:59 +08:00
|
|
|
|
"""Perform a Widevine key exchange and return negotiated keys."""
|
2026-05-30 10:52:45 +08:00
|
|
|
|
_log.info("Android Widevine handshake: sender=%s", sender)
|
2026-05-29 17:53:25 +08:00
|
|
|
|
if cookies:
|
|
|
|
|
|
session.cookies.update(cookies)
|
|
|
|
|
|
|
|
|
|
|
|
cache_path = Path(msl_keys_path)
|
2026-05-29 20:53:59 +08:00
|
|
|
|
msl_keys = cls.load_cache_data(cache_path)
|
2026-05-29 17:53:25 +08:00
|
|
|
|
if msl_keys is not None and not new_msl:
|
2026-05-30 10:52:45 +08:00
|
|
|
|
_log.info("Reusing cached MSL keys")
|
2026-05-29 17:53:25 +08:00
|
|
|
|
return msl_keys
|
2026-05-30 10:52:45 +08:00
|
|
|
|
_log.info("Performing fresh Widevine key exchange")
|
2026-05-29 17:53:25 +08:00
|
|
|
|
|
|
|
|
|
|
if drm != "widevine":
|
|
|
|
|
|
raise ValueError(f"Unsupported DRM mode: {drm}")
|
|
|
|
|
|
|
|
|
|
|
|
if not cdm:
|
2026-05-29 20:53:59 +08:00
|
|
|
|
raise ValueError("Widevine CDM is required for this Android MSL flow")
|
2026-05-29 17:53:25 +08:00
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
|
)
|
2026-05-30 10:52:45 +08:00
|
|
|
|
_log.debug("Widevine challenge created (%d bytes)", len(challenge))
|
2026-05-29 17:53:25 +08:00
|
|
|
|
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(
|
2026-05-29 20:53:59 +08:00
|
|
|
|
cls.generate_msg_header(
|
2026-05-29 17:53:25 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-30 10:52:45 +08:00
|
|
|
|
_log.debug("Widevine handshake request → %s", handshake_endpoint)
|
2026-05-29 20:53:59 +08:00
|
|
|
|
res = session.post(
|
|
|
|
|
|
url=handshake_endpoint, data=data, headers=handshake_headers, timeout=30
|
|
|
|
|
|
)
|
2026-05-30 10:52:45 +08:00
|
|
|
|
_log.debug("Widevine handshake response ← HTTP %d", res.status_code)
|
2026-05-29 17:53:25 +08:00
|
|
|
|
|
|
|
|
|
|
if res.status_code != 200:
|
2026-05-29 20:53:59 +08:00
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
f"Key exchange failed: HTTP {res.status_code} {res.text[:500]}"
|
|
|
|
|
|
)
|
2026-05-29 17:53:25 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-05-29 20:53:59 +08:00
|
|
|
|
decoded_error = base64.standard_b64decode(
|
|
|
|
|
|
key_exchange["errordata"]
|
|
|
|
|
|
).decode("utf-8")
|
2026-05-29 17:53:25 +08:00
|
|
|
|
error_json = json.loads(decoded_error)
|
|
|
|
|
|
raise RuntimeError(f"Key exchange failed: {error_json}")
|
|
|
|
|
|
|
|
|
|
|
|
if "headerdata" not in key_exchange:
|
2026-05-29 20:53:59 +08:00
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
f"Key exchange failed: missing headerdata in response: "
|
|
|
|
|
|
f"{str(key_exchange)[:500]}"
|
|
|
|
|
|
)
|
2026-05-29 17:53:25 +08:00
|
|
|
|
|
|
|
|
|
|
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"])
|
2026-05-29 20:53:59 +08:00
|
|
|
|
wv_keys = cdm.get_keys(msl_keys.cdm_session)
|
|
|
|
|
|
msl_keys.encryption = get_widevine_key(
|
2026-05-29 17:53:25 +08:00
|
|
|
|
kid=base64.standard_b64decode(key_data["encryptionkeyid"]),
|
2026-05-29 20:53:59 +08:00
|
|
|
|
keys=wv_keys,
|
2026-05-29 17:53:25 +08:00
|
|
|
|
permissions=["AllowEncrypt", "AllowDecrypt"],
|
|
|
|
|
|
)
|
2026-05-29 20:53:59 +08:00
|
|
|
|
msl_keys.sign = get_widevine_key(
|
2026-05-29 17:53:25 +08:00
|
|
|
|
kid=base64.standard_b64decode(key_data["hmackeyid"]),
|
2026-05-29 20:53:59 +08:00
|
|
|
|
keys=wv_keys,
|
2026-05-29 17:53:25 +08:00
|
|
|
|
permissions=["AllowSign", "AllowSignatureVerify"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
msl_keys.mastertoken = key_response_data["mastertoken"]
|
2026-05-29 20:53:59 +08:00
|
|
|
|
cls.cache_keys(msl_keys, cache_path)
|
2026-05-30 10:52:45 +08:00
|
|
|
|
_log.info("Widevine key exchange complete")
|
2026-05-29 20:53:59 +08:00
|
|
|
|
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.
|
|
|
|
|
|
"""
|
2026-05-30 10:52:45 +08:00
|
|
|
|
_log.info("Android RSA handshake: sender=%s", sender)
|
2026-05-29 20:53:59 +08:00
|
|
|
|
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:
|
2026-05-30 10:52:45 +08:00
|
|
|
|
_log.info("Reusing cached RSA MSL keys")
|
2026-05-29 20:53:59 +08:00
|
|
|
|
return cached
|
2026-05-30 10:52:45 +08:00
|
|
|
|
_log.info("Performing fresh RSA key exchange")
|
2026-05-29 20:53:59 +08:00
|
|
|
|
|
|
|
|
|
|
# ---- Generate ephemeral RSA-2048 keypair ----------------------------
|
|
|
|
|
|
rsa_key = RSA.generate(2048)
|
2026-05-30 10:52:45 +08:00
|
|
|
|
_log.debug("Generated RSA-2048 ephemeral keypair")
|
2026-05-29 20:53:59 +08:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-05-30 10:52:45 +08:00
|
|
|
|
_log.debug("RSA handshake request → %s", handshake_endpoint)
|
2026-05-29 20:53:59 +08:00
|
|
|
|
res = session.post(
|
|
|
|
|
|
url=handshake_endpoint,
|
|
|
|
|
|
data=data,
|
|
|
|
|
|
headers=handshake_headers,
|
|
|
|
|
|
timeout=30,
|
|
|
|
|
|
)
|
2026-05-30 10:52:45 +08:00
|
|
|
|
_log.debug("RSA handshake response ← HTTP %d", res.status_code)
|
2026-05-29 20:53:59 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-05-30 10:52:45 +08:00
|
|
|
|
_log.info("RSA key exchange complete")
|
2026-05-29 17:53:25 +08:00
|
|
|
|
return msl_keys
|
|
|
|
|
|
|
2026-05-29 20:53:59 +08:00
|
|
|
|
@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 -----------------------------------
|
|
|
|
|
|
|
2026-05-29 17:53:25 +08:00
|
|
|
|
@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]:
|
2026-05-29 20:53:59 +08:00
|
|
|
|
"""Build the HTTP headers required for Android MSL requests."""
|
2026-05-29 17:53:25 +08:00
|
|
|
|
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",
|
2026-05-29 20:53:59 +08:00
|
|
|
|
"x-netflix.request.id": "".join(
|
|
|
|
|
|
random.choice("0123456789abcdef") for _ in range(32)
|
|
|
|
|
|
),
|
2026-05-29 17:53:25 +08:00
|
|
|
|
"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
|
|
|
|
|
|
|
2026-05-29 20:53:59 +08:00
|
|
|
|
# -- Manifest defaults ---------------------------------------------------
|
|
|
|
|
|
|
2026-05-29 17:53:25 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def manifest_request_defaults() -> Tuple[str, Dict[str, str]]:
|
2026-05-29 20:53:59 +08:00
|
|
|
|
"""Return the default manifest endpoint and query params for Android."""
|
2026-05-29 17:53:25 +08:00
|
|
|
|
return MSL_ANDROID.DEFAULT_MANIFEST_ENDPOINT, dict(MSL_ANDROID.DEFAULT_MANIFEST_PARAMS)
|
|
|
|
|
|
|
2026-05-29 20:53:59 +08:00
|
|
|
|
# -- Override send_message to add PBO normalisation ----------------------
|
2026-05-29 17:53:25 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-29 20:53:59 +08:00
|
|
|
|
timeout: int = 30,
|
2026-05-29 17:53:25 +08:00
|
|
|
|
) -> Tuple[Dict[str, Any], Any]:
|
2026-05-29 20:53:59 +08:00
|
|
|
|
"""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,
|
2026-05-29 17:53:25 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-29 20:53:59 +08:00
|
|
|
|
__all__ = ["MSL_ANDROID", "MSLKeys"]
|