mirror of
https://github.com/Ap0dexMe0/NF-MSL.git
synced 2026-09-16 14:12:06 +02:00
524 lines
19 KiB
Python
524 lines
19 KiB
Python
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import logging
|
||
import random
|
||
from pathlib import Path
|
||
|
||
_log = logging.getLogger(__name__)
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
import jsonpickle
|
||
import requests
|
||
from Cryptodome.Cipher import PKCS1_OAEP, AES as _AES
|
||
from Cryptodome.Hash import SHA1
|
||
from Cryptodome.PublicKey import RSA
|
||
from Cryptodome.PublicKey.RSA import RsaKey
|
||
from pywidevine import Cdm as WidevineCdm, Device as WidevineDevice, PSSH
|
||
|
||
from modules.msl.base import MSLBase, MSLKeys as _BaseMSLKeys, get_widevine_key
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Platform-specific key container
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class MSLKeys(_BaseMSLKeys):
|
||
"""Android MSL keys – extends the base with an RSA key and CDM session."""
|
||
|
||
def __init__(
|
||
self,
|
||
encryption: Optional[bytes] = None,
|
||
sign: Optional[bytes] = None,
|
||
rsa: Optional[RsaKey] = None,
|
||
mastertoken: Optional[dict] = None,
|
||
cdm_session: Any = None,
|
||
) -> None:
|
||
super().__init__(encryption=encryption, sign=sign, mastertoken=mastertoken)
|
||
self.rsa = rsa
|
||
self.cdm_session = cdm_session
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# MSL_ANDROID
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class MSL_ANDROID(MSLBase):
|
||
"""Netflix MSL client for Android devices using Widevine DRM."""
|
||
|
||
# -- Platform constants --------------------------------------------------
|
||
DEFAULT_HANDSHAKE_ENDPOINT: str = (
|
||
"https://android.prod.ftl.netflix.com/nq/androidui/pbo_license/~1.0.0/router"
|
||
)
|
||
DEFAULT_MANIFEST_ENDPOINT: str = (
|
||
"https://android.prod.ftl.netflix.com/msl/playapi/android/manifest"
|
||
)
|
||
DEFAULT_MANIFEST_PARAMS: Dict[str, str] = {
|
||
"ab_ui_ver": "android",
|
||
"nrdapp_version": "18.26.0",
|
||
}
|
||
DEFAULT_USER_AGENT: str = (
|
||
"com.netflix.mediaclient/63988 "
|
||
"(Linux; U; Android 15; en_US; SM-F711N; "
|
||
"Build/AP3A.240905.015.A2; Cronet/143.0.7445.0)"
|
||
)
|
||
DEFAULT_REQUEST_CONTEXT: str = '{"appState":"foreground","appView":"unknown"}'
|
||
DEFAULT_NRDJS_VERSION: str = "v3.12.55"
|
||
DEFAULT_NETJS_VERSION: str = "3.0.5"
|
||
DEFAULT_PBO_VERSION: int = 2
|
||
DEFAULT_PBO_COMMON: Dict[str, str] = {
|
||
"sdk": "18.26.0",
|
||
"platform": "18.26.0",
|
||
"application": "Netflix Android 18.26.0",
|
||
"uiversion": "18.26.0",
|
||
"uiPlatform": "android",
|
||
"clientVersion": "18.26.0",
|
||
"apkVersion": "18.26.0",
|
||
}
|
||
DEFAULT_PBO_LANGUAGES: List[str] = ["en-US", "en"]
|
||
DEFAULT_DEVICE_MODEL: str = "SM-F711N"
|
||
|
||
# -- Constructor ---------------------------------------------------------
|
||
|
||
def __init__(
|
||
self,
|
||
session: requests.Session,
|
||
keys: MSLKeys,
|
||
message_id: int,
|
||
sender: str,
|
||
user_auth: Optional[dict] = None,
|
||
drm: str = "widevine",
|
||
proxy: Optional[Dict[str, str]] = None,
|
||
) -> None:
|
||
super().__init__(session=session, keys=keys, message_id=message_id, sender=sender, proxy=proxy)
|
||
self.user_auth = user_auth
|
||
self.drm = drm
|
||
|
||
# -- Widevine handshake --------------------------------------------------
|
||
|
||
@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:
|
||
"""Perform a Widevine key exchange and return negotiated keys."""
|
||
_log.info("Android Widevine handshake: sender=%s", sender)
|
||
if cookies:
|
||
session.cookies.update(cookies)
|
||
|
||
cache_path = Path(msl_keys_path)
|
||
msl_keys = cls.load_cache_data(cache_path)
|
||
if msl_keys is not None and not new_msl:
|
||
_log.info("Reusing cached MSL keys")
|
||
return msl_keys
|
||
_log.info("Performing fresh Widevine key exchange")
|
||
|
||
if drm != "widevine":
|
||
raise ValueError(f"Unsupported DRM mode: {drm}")
|
||
|
||
if not cdm:
|
||
raise ValueError("Widevine CDM is required for this Android 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),
|
||
)
|
||
_log.debug("Widevine challenge created (%d bytes)", len(challenge))
|
||
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(
|
||
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",
|
||
)
|
||
_log.debug("Widevine handshake request → %s", handshake_endpoint)
|
||
res = session.post(
|
||
url=handshake_endpoint, data=data, headers=handshake_headers, timeout=30
|
||
)
|
||
_log.debug("Widevine handshake response ← HTTP %d", res.status_code)
|
||
|
||
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: "
|
||
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"]
|
||
|
||
cdm.parse_license(msl_keys.cdm_session, key_data["cdmkeyresponse"])
|
||
wv_keys = cdm.get_keys(msl_keys.cdm_session)
|
||
msl_keys.encryption = get_widevine_key(
|
||
kid=base64.standard_b64decode(key_data["encryptionkeyid"]),
|
||
keys=wv_keys,
|
||
permissions=["AllowEncrypt", "AllowDecrypt"],
|
||
)
|
||
msl_keys.sign = get_widevine_key(
|
||
kid=base64.standard_b64decode(key_data["hmackeyid"]),
|
||
keys=wv_keys,
|
||
permissions=["AllowSign", "AllowSignatureVerify"],
|
||
)
|
||
|
||
msl_keys.mastertoken = key_response_data["mastertoken"]
|
||
cls.cache_keys(msl_keys, cache_path)
|
||
_log.info("Widevine key exchange complete")
|
||
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.
|
||
"""
|
||
_log.info("Android RSA handshake: sender=%s", sender)
|
||
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:
|
||
_log.info("Reusing cached RSA MSL keys")
|
||
return cached
|
||
_log.info("Performing fresh RSA key exchange")
|
||
|
||
# ---- Generate ephemeral RSA-2048 keypair ----------------------------
|
||
rsa_key = RSA.generate(2048)
|
||
_log.debug("Generated RSA-2048 ephemeral keypair")
|
||
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",
|
||
)
|
||
_log.debug("RSA handshake request → %s", handshake_endpoint)
|
||
res = session.post(
|
||
url=handshake_endpoint,
|
||
data=data,
|
||
headers=handshake_headers,
|
||
timeout=30,
|
||
)
|
||
_log.debug("RSA handshake response ← HTTP %d", res.status_code)
|
||
|
||
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)
|
||
_log.info("RSA key exchange complete")
|
||
return msl_keys
|
||
|
||
@staticmethod
|
||
def _decrypt_jwe_key(field_b64: str, rsa_private: RsaKey) -> bytes:
|
||
"""Decrypt a Netflix JWE-wrapped session key.
|
||
|
||
The field value is standard-base64-encoded JWE compact serialisation.
|
||
Netflix uses RSA-OAEP (SHA-1) for the CEK and A128GCM for content
|
||
but skips the AAD in the GCM tag — so we decrypt without verification
|
||
and trust the RSA layer for integrity.
|
||
"""
|
||
jwe_compact = base64.b64decode(field_b64 + "==").decode("utf-8")
|
||
hdr_b64, enc_cek_b64, iv_b64, ct_b64, _ = jwe_compact.split(".")
|
||
|
||
# Decrypt the Content Encryption Key (CEK) with RSA-OAEP / SHA-1
|
||
enc_cek = base64.urlsafe_b64decode(enc_cek_b64 + "==")
|
||
cek = PKCS1_OAEP.new(rsa_private, hashAlgo=SHA1).decrypt(enc_cek)
|
||
|
||
# Decrypt the payload with AES-128-GCM (no AAD verification)
|
||
iv = base64.urlsafe_b64decode(iv_b64 + "==")
|
||
ct = base64.urlsafe_b64decode(ct_b64 + "==")
|
||
plaintext = _AES.new(cek, _AES.MODE_GCM, nonce=iv).decrypt(ct)
|
||
|
||
# Payload is a JWK JSON — extract the raw key bytes
|
||
jwk = json.loads(plaintext.decode("utf-8"))
|
||
remainder = len(jwk["k"]) % 4
|
||
padded = jwk["k"] + "=" * (4 - remainder if remainder else 0)
|
||
return base64.urlsafe_b64decode(padded)
|
||
|
||
# -- Platform-specific request headers -----------------------------------
|
||
|
||
@staticmethod
|
||
def build_request_headers(
|
||
request_name: str,
|
||
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]:
|
||
"""Build the HTTP headers required for Android MSL requests."""
|
||
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
|
||
|
||
# -- Manifest defaults ---------------------------------------------------
|
||
|
||
@staticmethod
|
||
def manifest_request_defaults() -> Tuple[str, Dict[str, str]]:
|
||
"""Return the default manifest endpoint and query params for Android."""
|
||
return MSL_ANDROID.DEFAULT_MANIFEST_ENDPOINT, dict(MSL_ANDROID.DEFAULT_MANIFEST_PARAMS)
|
||
|
||
# -- Override send_message to add PBO normalisation ----------------------
|
||
|
||
def send_message(
|
||
self,
|
||
endpoint: str,
|
||
params: Dict[str, str],
|
||
application_data: Dict[str, Any],
|
||
userauthdata: Optional[dict] = None,
|
||
headers: Optional[dict] = None,
|
||
proxy: Optional[Dict[str, str]] = None,
|
||
timeout: int = 30,
|
||
) -> Tuple[Dict[str, Any], Any]:
|
||
"""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,
|
||
)
|
||
|
||
|
||
__all__ = ["MSL_ANDROID", "MSLKeys"]
|