forked from kenzuya/unshackle
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:
@@ -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,23 +54,25 @@ 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:
|
||||
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",
|
||||
@@ -76,7 +80,7 @@ class MSL:
|
||||
)
|
||||
|
||||
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:
|
||||
|
||||
@@ -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,6 +434,11 @@ class Netflix(Service):
|
||||
return result_profiles
|
||||
|
||||
def get_esn(self):
|
||||
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:
|
||||
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
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user