Files
NF-MSL/modules/msl/android.py
T

524 lines
19 KiB
Python
Raw Normal View History

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"]