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
|
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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user