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
from datetime import datetime
from io import BytesIO
from typing import Optional, Any
import jsonpickle
import requests
@@ -26,7 +27,8 @@ from .schemes import EntityAuthenticationSchemes # noqa: F401
from .schemes import KeyExchangeSchemes
from .schemes.EntityAuthentication import EntityAuthentication
from .schemes.KeyExchangeRequest import KeyExchangeRequest
# from vinetrimmer.utils.widevine.device import RemoteDevice
from pywidevine.cdm import Cdm
from pywidevine.pssh import PSSH
class MSL:
log = logging.getLogger("MSL")
@@ -40,7 +42,7 @@ class MSL:
self.message_id = message_id
@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)
message_id = random.randint(0, pow(2, 52))
msl_keys = MSL.load_cache_data(cache)
@@ -52,31 +54,33 @@ class MSL:
if scheme != KeyExchangeSchemes.Widevine:
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:
# raise cls.log.exit("- No cached data and no MSL key path specified")
if scheme == KeyExchangeSchemes.Widevine:
if not cdm:
raise Exception('Key exchange scheme Widevine but CDM instance is None.')
# Key Exchange Scheme Widevine currently not implemented
# if scheme == KeyExchangeSchemes.Widevine:
# msl_keys.cdm_session = cdm.open(
# pssh=b"\x0A\x7A\x00\x6C\x38\x2B",
# raw=True,
# offline=True
# )
# keyrequestdata = KeyExchangeRequest.Widevine(
# keyrequest=cdm.get_license_challenge(msl_keys.cdm_session)
# )
# else:
keyrequestdata = KeyExchangeRequest.AsymmetricWrapped(
keypairid="superKeyPair",
mechanism="JWK_RSA",
publickey=msl_keys.rsa.publickey().exportKey(format="DER")
)
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,
)
keyrequestdata = KeyExchangeRequest.Widevine(challenge)
entityauthdata = EntityAuthentication.Widevine("TV", base64.b64encode(challenge).decode())
else:
entityauthdata = EntityAuthentication.Unauthenticated(sender)
keyrequestdata = KeyExchangeRequest.AsymmetricWrapped(
keypairid="superKeyPair",
mechanism="JWK_RSA",
publickey=msl_keys.rsa.publickey().exportKey(format="DER")
)
data = jsonpickle.encode({
"entityauthdata": EntityAuthentication.Unauthenticated(sender),
"entityauthdata": entityauthdata,
"headerdata": base64.b64encode(MSL.generate_msg_header(
message_id=message_id,
sender=sender,
@@ -101,11 +105,11 @@ class MSL:
data=data
)
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
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"]
).decode())["errormsg"])
@@ -115,7 +119,7 @@ class MSL:
).decode("utf-8"))["keyresponsedata"]
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"]
# if scheme == KeyExchangeSchemes.Widevine:

View File

@@ -104,6 +104,10 @@ class Netflix(Service):
self.subs_only = ctx.parent.params.get("subs_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:
# Configure first before download
@@ -382,6 +386,9 @@ class Netflix(Service):
self.profiles = self.get_profiles()
self.log.info("Intializing a MSL client")
self.get_esn()
# if self.cdm.security_level == 1:
# scheme = KeyExchangeSchemes.Widevine
# else:
scheme = KeyExchangeSchemes.AsymmetricWrapped
self.log.info(f"Scheme: {scheme}")
@@ -391,7 +398,9 @@ class Netflix(Service):
session=self.session,
endpoint=self.config["endpoints"]["manifest"],
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()
self.userauthdata = UserAuthentication.NetflixIDCookies(
@@ -425,15 +434,20 @@ class Netflix(Service):
return result_profiles
def get_esn(self):
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")
self.log.info(f"Security level: {self.cdm.security_level}")
if int(self.cdm.security_level) == 1:
# Use ESN map from config.yaml instead of generating a new one
self.esn.set(self.config["esn_map"][self.cdm.system_id])
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}")
@@ -516,6 +530,9 @@ class Netflix(Service):
"reqPriority": 10,
"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(
endpoint=self.config["endpoints"]["manifest"],
params=params,
@@ -529,7 +546,9 @@ class Netflix(Service):
"params": {
"clientVersion": "6.0051.090.911",
"challenge": self.config["payload_challenge_pr"] if self.drm_system == 'playready' else self.config["payload_challenge"],
# "challenge": base64.b64encode(challenge).decode(),
"challanges": {
# "default": base64.b64encode(challenge).decode()
"default": self.config["payload_challenge_pr"] if self.drm_system == 'playready' else self.config["payload_challenge"]
},
"contentPlaygraph": ["v2"],
@@ -588,6 +607,7 @@ class Netflix(Service):
},
userauthdata=self.userauthdata
)
# self.cdm.close(session_id)
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']}")
return self._get_empty_manifest()

View File

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