feat(netflix): add Widevine CDM integration with MSL handshake and ESN mapping

- Extend MSL handshake method to support Widevine key exchange scheme using CDM instance
- Implement CDM session handling with service certificate and license challenge during handshake
- Add exception handling for key exchange errors instead of exiting logger
- Modify Netflix service to include CDM instance and pass it to MSL handshake call
- Update get_esn method to use ESN mapping from config for security level 1 CDM systems
- Add new ESN mapping entry in config.yaml for a specific CDM SystemID
- Remove commented out Widevine key exchange placeholder code and replace with full implementation
- Include CDM initialization logs and tweak manifest params to support DRM challenges
- Ensure fallback to random ESN generation for non-level 1 security or missing cached ESN
This commit is contained in:
2025-08-28 02:26:44 +07:00
parent d18fbdb542
commit e1f69eb307
3 changed files with 60 additions and 35 deletions

View File

@@ -10,6 +10,7 @@ import time
import zlib import zlib
from datetime import datetime from datetime import datetime
from io import BytesIO from io import BytesIO
from typing import Optional, Any
import jsonpickle import jsonpickle
import requests import requests
@@ -26,7 +27,8 @@ from .schemes import EntityAuthenticationSchemes # noqa: F401
from .schemes import KeyExchangeSchemes from .schemes import KeyExchangeSchemes
from .schemes.EntityAuthentication import EntityAuthentication from .schemes.EntityAuthentication import EntityAuthentication
from .schemes.KeyExchangeRequest import KeyExchangeRequest from .schemes.KeyExchangeRequest import KeyExchangeRequest
# from vinetrimmer.utils.widevine.device import RemoteDevice from pywidevine.cdm import Cdm
from pywidevine.pssh import PSSH
class MSL: class MSL:
log = logging.getLogger("MSL") log = logging.getLogger("MSL")
@@ -40,7 +42,7 @@ class MSL:
self.message_id = message_id self.message_id = message_id
@classmethod @classmethod
def handshake(cls, scheme: KeyExchangeSchemes, session: requests.Session, endpoint: str, sender: str, cache: Cacher): def handshake(cls, scheme: KeyExchangeSchemes, session: requests.Session, endpoint: str, sender: str, cache: Cacher, cdm: Optional[Cdm] = None, config: Any = None):
cache = cache.get(sender) cache = cache.get(sender)
message_id = random.randint(0, pow(2, 52)) message_id = random.randint(0, pow(2, 52))
msl_keys = MSL.load_cache_data(cache) msl_keys = MSL.load_cache_data(cache)
@@ -52,31 +54,33 @@ class MSL:
if scheme != KeyExchangeSchemes.Widevine: if scheme != KeyExchangeSchemes.Widevine:
msl_keys.rsa = RSA.generate(2048) msl_keys.rsa = RSA.generate(2048)
# if not cdm:
# raise cls.log.exit("- No cached data and no CDM specified")
# if not msl_keys_path: if scheme == KeyExchangeSchemes.Widevine:
# raise cls.log.exit("- No cached data and no MSL key path specified") if not cdm:
raise Exception('Key exchange scheme Widevine but CDM instance is None.')
session_id = cdm.open()
msl_keys.cdm_session = session_id
cdm.set_service_certificate(session_id, config["certificate"])
challenge = cdm.get_license_challenge(
session_id=session_id,
pssh=PSSH("AAAANHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABQIARIQAAAAAAPSZ0kAAAAAAAAAAA=="),
license_type="OFFLINE",
privacy_mode=True,
# Key Exchange Scheme Widevine currently not implemented )
# if scheme == KeyExchangeSchemes.Widevine: keyrequestdata = KeyExchangeRequest.Widevine(challenge)
# msl_keys.cdm_session = cdm.open( entityauthdata = EntityAuthentication.Widevine("TV", base64.b64encode(challenge).decode())
# pssh=b"\x0A\x7A\x00\x6C\x38\x2B", else:
# raw=True, entityauthdata = EntityAuthentication.Unauthenticated(sender)
# offline=True keyrequestdata = KeyExchangeRequest.AsymmetricWrapped(
# ) keypairid="superKeyPair",
# keyrequestdata = KeyExchangeRequest.Widevine( mechanism="JWK_RSA",
# keyrequest=cdm.get_license_challenge(msl_keys.cdm_session) publickey=msl_keys.rsa.publickey().exportKey(format="DER")
# ) )
# else:
keyrequestdata = KeyExchangeRequest.AsymmetricWrapped(
keypairid="superKeyPair",
mechanism="JWK_RSA",
publickey=msl_keys.rsa.publickey().exportKey(format="DER")
)
data = jsonpickle.encode({ data = jsonpickle.encode({
"entityauthdata": EntityAuthentication.Unauthenticated(sender), "entityauthdata": entityauthdata,
"headerdata": base64.b64encode(MSL.generate_msg_header( "headerdata": base64.b64encode(MSL.generate_msg_header(
message_id=message_id, message_id=message_id,
sender=sender, sender=sender,
@@ -101,11 +105,11 @@ class MSL:
data=data data=data
) )
except requests.HTTPError as e: except requests.HTTPError as e:
raise cls.log.exit(f"- Key exchange failed, response data is unexpected: {e.response.text}") raise Exception(f"- Key exchange failed, response data is unexpected: {e.response.text}")
key_exchange = r.json() # expecting no payloads, so this is fine key_exchange = r.json() # expecting no payloads, so this is fine
if "errordata" in key_exchange: if "errordata" in key_exchange:
raise cls.log.exit("- Key exchange failed: " + json.loads(base64.b64decode( raise Exception("- Key exchange failed: " + json.loads(base64.b64decode(
key_exchange["errordata"] key_exchange["errordata"]
).decode())["errormsg"]) ).decode())["errormsg"])
@@ -115,7 +119,7 @@ class MSL:
).decode("utf-8"))["keyresponsedata"] ).decode("utf-8"))["keyresponsedata"]
if key_response_data["scheme"] != str(scheme): if key_response_data["scheme"] != str(scheme):
raise cls.log.exit("- Key exchange scheme mismatch occurred") raise Exception("- Key exchange scheme mismatch occurred")
key_data = key_response_data["keydata"] key_data = key_response_data["keydata"]
# if scheme == KeyExchangeSchemes.Widevine: # if scheme == KeyExchangeSchemes.Widevine:

View File

@@ -104,6 +104,10 @@ class Netflix(Service):
self.subs_only = ctx.parent.params.get("subs_only") self.subs_only = ctx.parent.params.get("subs_only")
self.chapters_only = ctx.parent.params.get("chapters_only") self.chapters_only = ctx.parent.params.get("chapters_only")
# Inherited from unshackle
self.cdm: Cdm = ctx.obj.cdm
# self.ctx = ctx
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
# Configure first before download # Configure first before download
@@ -382,6 +386,9 @@ class Netflix(Service):
self.profiles = self.get_profiles() self.profiles = self.get_profiles()
self.log.info("Intializing a MSL client") self.log.info("Intializing a MSL client")
self.get_esn() self.get_esn()
# if self.cdm.security_level == 1:
# scheme = KeyExchangeSchemes.Widevine
# else:
scheme = KeyExchangeSchemes.AsymmetricWrapped scheme = KeyExchangeSchemes.AsymmetricWrapped
self.log.info(f"Scheme: {scheme}") self.log.info(f"Scheme: {scheme}")
@@ -391,7 +398,9 @@ class Netflix(Service):
session=self.session, session=self.session,
endpoint=self.config["endpoints"]["manifest"], endpoint=self.config["endpoints"]["manifest"],
sender=self.esn.data, sender=self.esn.data,
cache=self.cache.get("MSL") cache=self.cache.get("MSL"),
cdm=self.cdm,
config=self.config,
) )
cookie = self.session.cookies.get_dict() cookie = self.session.cookies.get_dict()
self.userauthdata = UserAuthentication.NetflixIDCookies( self.userauthdata = UserAuthentication.NetflixIDCookies(
@@ -425,15 +434,20 @@ class Netflix(Service):
return result_profiles return result_profiles
def get_esn(self): def get_esn(self):
ESN_GEN = "".join(random.choice("0123456789ABCDEF") for _ in range(30)) self.log.info(f"Security level: {self.cdm.security_level}")
esn_value = f"NFCDIE-03-{ESN_GEN}" if int(self.cdm.security_level) == 1:
# Check if ESN is expired or doesn't exist # Use ESN map from config.yaml instead of generating a new one
if self.esn.data is None or self.esn.data == {} or (hasattr(self.esn, 'expired') and self.esn.expired): self.esn.set(self.config["esn_map"][self.cdm.system_id])
# Set new ESN with 6-hour expiration
self.esn.set(esn_value, 1 * 60 * 60) # 6 hours in seconds
self.log.info(f"Generated new ESN with 1-hour expiration")
else: else:
self.log.info(f"Using cached ESN.") ESN_GEN = "".join(random.choice("0123456789ABCDEF") for _ in range(30))
esn_value = f"NFCDIE-03-{ESN_GEN}"
# Check if ESN is expired or doesn't exist
if self.esn.data is None or self.esn.data == {} or (hasattr(self.esn, 'expired') and self.esn.expired):
# Set new ESN with 6-hour expiration
self.esn.set(esn_value, 1 * 60 * 60) # 6 hours in seconds
self.log.info(f"Generated new ESN with 1-hour expiration")
else:
self.log.info(f"Using cached ESN.")
self.log.info(f"ESN: {self.esn.data}") self.log.info(f"ESN: {self.esn.data}")
@@ -516,6 +530,9 @@ class Netflix(Service):
"reqPriority": 10, "reqPriority": 10,
"reqName": "manifest", "reqName": "manifest",
} }
# session_id = self.cdm.open()
# self.cdm.set_service_certificate(session_id, self.config["certificate"])
# challenge = self.cdm.get_license_challenge(session_id, PSSH("AAAANHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABQIARIQAAAAAAPSZ0kAAAAAAAAAAA=="))
_, payload_chunks = self.msl.send_message( _, payload_chunks = self.msl.send_message(
endpoint=self.config["endpoints"]["manifest"], endpoint=self.config["endpoints"]["manifest"],
params=params, params=params,
@@ -529,7 +546,9 @@ class Netflix(Service):
"params": { "params": {
"clientVersion": "6.0051.090.911", "clientVersion": "6.0051.090.911",
"challenge": self.config["payload_challenge_pr"] if self.drm_system == 'playready' else self.config["payload_challenge"], "challenge": self.config["payload_challenge_pr"] if self.drm_system == 'playready' else self.config["payload_challenge"],
# "challenge": base64.b64encode(challenge).decode(),
"challanges": { "challanges": {
# "default": base64.b64encode(challenge).decode()
"default": self.config["payload_challenge_pr"] if self.drm_system == 'playready' else self.config["payload_challenge"] "default": self.config["payload_challenge_pr"] if self.drm_system == 'playready' else self.config["payload_challenge"]
}, },
"contentPlaygraph": ["v2"], "contentPlaygraph": ["v2"],
@@ -588,6 +607,7 @@ class Netflix(Service):
}, },
userauthdata=self.userauthdata userauthdata=self.userauthdata
) )
# self.cdm.close(session_id)
if "errorDetails" in payload_chunks: if "errorDetails" in payload_chunks:
self.log.error(f"Manifest call failed for title_id: {title_id}, required_audio_track_id: {required_audio_track_id}, required_text_track_id: {required_text_track_id}, error: {payload_chunks['errorDetails']}") self.log.error(f"Manifest call failed for title_id: {title_id}, required_audio_track_id: {required_audio_track_id}, required_text_track_id: {required_text_track_id}, error: {payload_chunks['errorDetails']}")
return self._get_empty_manifest() return self._get_empty_manifest()

View File

@@ -14,6 +14,7 @@ payload_challenge_pr: "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48c29hc
esn_map: esn_map:
# key map of CDM WVD `SystemID = 'ESN you want to use for that CDM WVD'` # key map of CDM WVD `SystemID = 'ESN you want to use for that CDM WVD'`
8159: "NFANDROID1-PRV-P-GOOGLEPIXEL" 8159: "NFANDROID1-PRV-P-GOOGLEPIXEL"
8131: "HISETVK84500000000000000000000000007401422"
endpoints: endpoints:
website: "https://www.netflix.com/nq/website/memberapi/{build_id}/pathEvaluator" website: "https://www.netflix.com/nq/website/memberapi/{build_id}/pathEvaluator"
manifest: "https://www.netflix.com/msl/playapi/cadmium/licensedmanifest/1" manifest: "https://www.netflix.com/msl/playapi/cadmium/licensedmanifest/1"