X-Git-Url: http://git.code-monkey.de/?a=blobdiff_plain;f=resources%2Flib%2FMSL.py;h=a5de5384bf1f3cca7372e9a6dce21db64c1dc344;hb=6f5adf6901bd63668d8b069d9064b72c26fb1b6c;hp=163da5307f13f4c2e6019cd5acdc5467da965ce4;hpb=3305a89c90f475629a06f4ab96fa6fdf9098dd2f;p=plugin.video.netflix.git diff --git a/resources/lib/MSL.py b/resources/lib/MSL.py index 163da53..a5de538 100644 --- a/resources/lib/MSL.py +++ b/resources/lib/MSL.py @@ -1,3 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Module: MSL +# Created on: 26.01.2017 + import base64 import gzip import json @@ -5,27 +10,19 @@ import os import pprint import random from StringIO import StringIO -from hmac import HMAC -import hashlib + +from datetime import datetime import requests import zlib import time -from Crypto.PublicKey import RSA -from Crypto.Cipher import PKCS1_OAEP -from Crypto.Cipher import AES -from Crypto.Random import get_random_bytes -# from Crypto.Hash import HMAC, SHA256 -from Crypto.Util import Padding +from Cryptodome.PublicKey import RSA +from Cryptodome.Cipher import PKCS1_OAEP +from Cryptodome.Cipher import AES +from Cryptodome.Random import get_random_bytes +from Cryptodome.Hash import HMAC, SHA256 +from Cryptodome.Util import Padding import xml.etree.ElementTree as ET -from KodiHelper import KodiHelper - -plugin_handle = int(sys.argv[1]) -base_url = sys.argv[0] -kodi_helper = KodiHelper( - plugin_handle=plugin_handle, - base_url=base_url -) pp = pprint.PrettyPrinter(indent=4) @@ -45,7 +42,8 @@ class MSL: last_drm_context = '' last_playback_context = '' #esn = "NFCDCH-LX-CQE0NU6PA5714R25VPLXVU2A193T36" - esn = "WWW-BROWSE-D7GW1G4NPXGR1F0X1H3EQGY3V1F5WE" + #esn = "WWW-BROWSE-D7GW1G4NPXGR1F0X1H3EQGY3V1F5WE" + #esn = "NFCDIE-02-DCH84Q2EK3N6VFVQJ0NLRQ27498N0F" current_message_id = 0 session = requests.session() rndm = random.SystemRandom() @@ -55,44 +53,114 @@ class MSL: 'license': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/license' } - def __init__(self, email, password): + def __init__(self, kodi_helper): """ The Constructor checks for already existing crypto Keys. If they exist it will load the existing keys """ - self.email = email - self.password = password + self.kodi_helper = kodi_helper try: - os.mkdir(kodi_helper.msl_data_path) + os.mkdir(self.kodi_helper.msl_data_path) except OSError: pass - if self.file_exists('msl_data.json'): + if self.file_exists(self.kodi_helper.msl_data_path, 'msl_data.json'): + self.kodi_helper.log(msg='MSL Data exists. Use old Tokens.') self.__load_msl_data() self.handshake_performed = True - elif self.file_exists('rsa_key.bin'): - kodi_helper.log(msg='RSA Keys do already exist load old ones') + elif self.file_exists(self.kodi_helper.msl_data_path, 'rsa_key.bin'): + self.kodi_helper.log(msg='RSA Keys do already exist load old ones') self.__load_rsa_keys() self.__perform_key_handshake() else: - kodi_helper.log(msg='Create new RSA Keys') + self.kodi_helper.log(msg='Create new RSA Keys') # Create new Key Pair and save self.rsa_key = RSA.generate(2048) self.__save_rsa_keys() self.__perform_key_handshake() def load_manifest(self, viewable_id): + """ + Loads the manifets for the given viewable_id and returns a mpd-XML-Manifest + :param viewable_id: The id of of the viewable + :return: MPD XML Manifest or False if no success + """ manifest_request_data = { 'method': 'manifest', 'lookupType': 'PREPARE', 'viewableIds': [viewable_id], 'profiles': [ - 'playready-h264mpl30-dash', - 'playready-h264mpl31-dash', + "playready-h264bpl30-dash", + "playready-h264mpl30-dash", + "playready-h264mpl31-dash", + "playready-h264mpl40-dash", + # "hevc-main-L30-dash-cenc", + # "hevc-main-L31-dash-cenc", + # "hevc-main-L40-dash-cenc", + # "hevc-main-L41-dash-cenc", + # "hevc-main-L50-dash-cenc", + # "hevc-main-L51-dash-cenc", + # "hevc-main10-L30-dash-cenc", + # "hevc-main10-L31-dash-cenc", + # "hevc-main10-L40-dash-cenc", + # "hevc-main10-L41-dash-cenc", + # "hevc-main10-L50-dash-cenc", + # "hevc-main10-L51-dash-cenc", + # "hevc-main10-L30-dash-cenc-prk", + # "hevc-main10-L31-dash-cenc-prk", + # "hevc-main10-L40-dash-cenc-prk", + # "hevc-main10-L41-dash-cenc-prk", + # "hevc-main-L30-L31-dash-cenc-tl", + # "hevc-main-L31-L40-dash-cenc-tl", + # "hevc-main-L40-L41-dash-cenc-tl", + # "hevc-main-L50-L51-dash-cenc-tl", + # "hevc-main10-L30-L31-dash-cenc-tl", + # "hevc-main10-L31-L40-dash-cenc-tl", + # "hevc-main10-L40-L41-dash-cenc-tl", + # "hevc-main10-L50-L51-dash-cenc-tl", + # "hevc-dv-main10-L30-dash-cenc", + # "hevc-dv-main10-L31-dash-cenc", + # "hevc-dv-main10-L40-dash-cenc", + # "hevc-dv-main10-L41-dash-cenc", + # "hevc-dv-main10-L50-dash-cenc", + # "hevc-dv-main10-L51-dash-cenc", + # "hevc-dv5-main10-L30-dash-cenc-prk", + # "hevc-dv5-main10-L31-dash-cenc-prk", + # "hevc-dv5-main10-L40-dash-cenc-prk", + # "hevc-dv5-main10-L41-dash-cenc-prk", + # "hevc-dv5-main10-L50-dash-cenc-prk", + # "hevc-dv5-main10-L51-dash-cenc-prk", + # "hevc-hdr-main10-L30-dash-cenc", + # "hevc-hdr-main10-L31-dash-cenc", + # "hevc-hdr-main10-L40-dash-cenc", + # "hevc-hdr-main10-L41-dash-cenc", + # "hevc-hdr-main10-L50-dash-cenc", + # "hevc-hdr-main10-L51-dash-cenc", + # "hevc-hdr-main10-L30-dash-cenc-prk", + # "hevc-hdr-main10-L31-dash-cenc-prk", + # "hevc-hdr-main10-L40-dash-cenc-prk", + # "hevc-hdr-main10-L41-dash-cenc-prk", + # "hevc-hdr-main10-L50-dash-cenc-prk", + # "hevc-hdr-main10-L51-dash-cenc-prk" + + # 'playready-h264mpl30-dash', + #'playready-h264mpl31-dash', + #'playready-h264mpl40-dash', + #'hevc-main10-L41-dash-cenc', + #'hevc-main10-L50-dash-cenc', + #'hevc-main10-L51-dash-cenc', + + + + # Audio 'heaac-2-dash', - 'dfxp-ls-sdh', + + #subtiltes + #'dfxp-ls-sdh', 'simplesdh', - 'nflx-cmisc', + #'nflx-cmisc', + + #unkown 'BIF240', 'BIF320' ], @@ -112,39 +180,35 @@ class MSL: 'clientVersion': '4.0004.899.011', 'uiVersion': 'akira' } - request_data = self.__generate_msl_request_data(manifest_request_data) - resp = self.session.post(self.endpoints['manifest'], request_data) + # Check if dolby sound is enabled and add to profles + if self.kodi_helper.get_dolby_setting(): + manifest_request_data['profiles'].append('ddplus-2.0-dash') + manifest_request_data['profiles'].append('ddplus-5.1-dash') + request_data = self.__generate_msl_request_data(manifest_request_data) + resp = self.session.post(self.endpoints['manifest'], request_data) try: + # if the json() does not fail we have an error because the manifest response is a chuncked json response resp.json() - kodi_helper.log(msg='MANIFEST RESPONE JSON: '+resp.text) + self.kodi_helper.log(msg='Error getting Manifest: '+resp.text) + return False except ValueError: - # Maybe we have a CHUNKED response + # json() failed so parse the chunked response + self.kodi_helper.log(msg='Got chunked Manifest Response: ' + resp.text) resp = self.__parse_chunked_msl_response(resp.text) + self.kodi_helper.log(msg='Parsed chunked Response: ' + json.dumps(resp)) data = self.__decrypt_payload_chunk(resp['payloads'][0]) - # pprint.pprint(data) return self.__tranform_to_dash(data) - def get_license(self, challenge, sid): - """ - std::time_t t = std::time(0); // t is an integer type - licenseRequestData["clientTime"] = (int)t; - //licenseRequestData["challengeBase64"] = challengeStr; - licenseRequestData["licenseType"] = "STANDARD"; - licenseRequestData["playbackContextId"] = playbackContextId;//"E1-BQFRAAELEB32o6Se-GFvjwEIbvDydEtfj6zNzEC3qwfweEPAL3gTHHT2V8rS_u1Mc3mw5BWZrUlKYIu4aArdjN8z_Z8t62E5jRjLMdCKMsVhlSJpiQx0MNW4aGqkYz-1lPh85Quo4I_mxVBG5lgd166B5NDizA8."; - licenseRequestData["drmContextIds"] = Json::arrayValue; - licenseRequestData["drmContextIds"].append(drmContextId); - - :param viewable_id: - :param challenge: - :param kid: - :return: + Requests and returns a license for the given challenge and sid + :param challenge: The base64 encoded challenge + :param sid: The sid paired to the challengew + :return: Base64 representation of the license key or False if no success """ - license_request_data = { 'method': 'license', 'licenseType': 'STANDARD', @@ -166,18 +230,19 @@ class MSL: resp = self.session.post(self.endpoints['license'], request_data) try: + # If is valid json the request for the licnese failed resp.json() - kodi_helper.log(msg='LICENSE RESPONE JSON: '+resp.text) + self.kodi_helper.log(msg='Error getting license: '+resp.text) + return False except ValueError: - # Maybe we have a CHUNKED response + # json() failed so we have a chunked json response resp = self.__parse_chunked_msl_response(resp.text) data = self.__decrypt_payload_chunk(resp['payloads'][0]) - # pprint.pprint(data) if data['success'] is True: return data['result']['licenses'][0]['data'] else: - return '' - + self.kodi_helper.log(msg='Error getting license: ' + json.dumps(data)) + return False def __decrypt_payload_chunk(self, payloadchunk): payloadchunk = json.JSONDecoder().decode(payloadchunk) @@ -202,7 +267,7 @@ class MSL: def __tranform_to_dash(self, manifest): - self.save_file('manifest.json', json.dumps(manifest)) + self.save_file(self.kodi_helper.msl_data_path, 'manifest.json', json.dumps(manifest)) manifest = manifest['result']['viewables'][0] self.last_playback_context = manifest['playbackContextId'] @@ -214,15 +279,14 @@ class MSL: if len(manifest['psshb64']) >= 1: pssh = manifest['psshb64'][0] - + seconds = manifest['runtime']/1000 + init_length = seconds / 2 * 12 + 20*1000 + duration = "PT"+str(seconds)+".00S" root = ET.Element('MPD') root.attrib['xmlns'] = 'urn:mpeg:dash:schema:mpd:2011' root.attrib['xmlns:cenc'] = 'urn:mpeg:cenc:2013' - - - seconds = manifest['runtime']/1000 - duration = "PT"+str(seconds)+".00S" + root.attrib['mediaPresentationDuration'] = duration period = ET.SubElement(root, 'Period', start='PT0S', duration=duration) @@ -236,18 +300,30 @@ class MSL: ET.SubElement(protection, 'cenc:pssh').text = pssh for downloadable in video_track['downloadables']: + + codec = 'h264' + if 'hevc' in downloadable['contentProfile']: + codec = 'hevc' + + hdcp_versions = '0.0' + for hdcp in downloadable['hdcpVersions']: + if hdcp != 'none': + hdcp_versions = hdcp + rep = ET.SubElement(video_adaption_set, 'Representation', width=str(downloadable['width']), height=str(downloadable['height']), bandwidth=str(downloadable['bitrate']*1024), - codecs='h264', + hdcp=hdcp_versions, + nflxContentProfile=str(downloadable['contentProfile']), + codecs=codec, mimeType='video/mp4') #BaseURL ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls']) # Init an Segment block - segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true") - ET.SubElement(segment_base, 'Initialization', range='0-60000') + segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-"+str(init_length), indexRangeExact="true") + ET.SubElement(segment_base, 'Initialization', range='0-'+str(init_length)) @@ -258,8 +334,13 @@ class MSL: contentType='audio', mimeType='audio/mp4') for downloadable in audio_track['downloadables']: + codec = 'aac' + print downloadable + if downloadable['contentProfile'] == 'ddplus-2.0-dash' or downloadable['contentProfile'] == 'ddplus-5.1-dash': + codec = 'ec-3' + print "codec is: " + codec rep = ET.SubElement(audio_adaption_set, 'Representation', - codecs='aac', + codecs=codec, bandwidth=str(downloadable['bitrate']*1024), mimeType='audio/mp4') @@ -271,8 +352,23 @@ class MSL: #BaseURL ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls']) # Index range - segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true") - ET.SubElement(segment_base, 'Initialization', range='0-60000') + segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-"+str(init_length), indexRangeExact="true") + ET.SubElement(segment_base, 'Initialization', range='0-'+str(init_length)) + + # Multiple Adaption Sets for subtiles + for text_track in manifest['textTracks']: + if 'downloadables' not in text_track or text_track['downloadables'] is None: + continue + subtiles_adaption_set = ET.SubElement(period, 'AdaptationSet', + lang=text_track['bcp47'], + codecs='stpp', + contentType='text', + mimeType='application/ttml+xml') + for downloadable in text_track['downloadables']: + rep = ET.SubElement(subtiles_adaption_set, 'Representation', + nflxProfile=downloadable['contentProfile'] + ) + ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls']) xml = ET.tostring(root, encoding='utf-8', method='xml') @@ -310,6 +406,7 @@ class MSL: } def __generate_msl_request_data(self, data): + self.__load_msl_data() header_encryption_envelope = self.__encrypt(self.__generate_msl_header()) header = { 'headerdata': base64.standard_b64encode(header_encryption_envelope), @@ -320,7 +417,7 @@ class MSL: # Serialize the given Data serialized_data = json.dumps(data) serialized_data = serialized_data.replace('"', '\\"') - serialized_data = '[{},{"headers":{},"path":"/cbp/cadmium-11","payload":{"data":"' + serialized_data + '"},"query":""}]\n' + serialized_data = '[{},{"headers":{},"path":"/cbp/cadmium-13","payload":{"data":"' + serialized_data + '"},"query":""}]\n' compressed_data = self.__compress_data(serialized_data) @@ -338,21 +435,7 @@ class MSL: 'signature': self.__sign(first_payload_encryption_envelope), } - - # Create Second Payload - second_payload = { - "messageid": self.current_message_id, - "data": "", - "endofmsg": True, - "sequencenumber": 2 - } - second_payload_encryption_envelope = self.__encrypt(json.dumps(second_payload)) - second_payload_chunk = { - 'payload': base64.standard_b64encode(second_payload_encryption_envelope), - 'signature': base64.standard_b64encode(self.__sign(second_payload_encryption_envelope)), - } - - request_data = json.dumps(header) + json.dumps(first_payload_chunk) # + json.dumps(second_payload_chunk) + request_data = json.dumps(header) + json.dumps(first_payload_chunk) return request_data @@ -371,9 +454,10 @@ class MSL: :return: The base64 encoded JSON String of the header """ self.current_message_id = self.rndm.randint(0, pow(2, 52)) + esn = self.kodi_helper.get_esn() header_data = { - 'sender': self.esn, + 'sender': esn, 'handshake': is_handshake, 'nonreplayable': False, 'capabilities': { @@ -405,12 +489,13 @@ class MSL: if 'usertoken' in self.tokens: pass else: + account = self.kodi_helper.get_credentials() # Auth via email and password header_data['userauthdata'] = { 'scheme': 'EMAIL_PASSWORD', 'authdata': { - 'email': self.email, - 'password': self.password + 'email': account['email'], + 'password': account['password'] } } @@ -424,10 +509,11 @@ class MSL: :param plaintext: :return: Serialized JSON String of the encryption Envelope """ + esn = self.kodi_helper.get_esn() iv = get_random_bytes(16) encryption_envelope = { 'ciphertext': '', - 'keyid': self.esn + '_' + str(self.sequence_number), + 'keyid': esn + '_' + str(self.sequence_number), 'sha256': 'AA==', 'iv': base64.standard_b64encode(iv) } @@ -439,44 +525,44 @@ class MSL: encryption_envelope['ciphertext'] = base64.standard_b64encode(ciphertext) return json.dumps(encryption_envelope) - def __sign(self, text): - #signature = hmac.new(self.sign_key, text, hashlib.sha256).digest() - signature = HMAC(self.sign_key, text, hashlib.sha256).digest() - - # hmac = HMAC.new(self.sign_key, digestmod=SHA256) - # hmac.update(text) + def __sign(self, text): + """ + Calculates the HMAC signature for the given text with the current sign key and SHA256 + :param text: + :return: Base64 encoded signature + """ + signature = HMAC.new(self.sign_key, text, SHA256).digest() return base64.standard_b64encode(signature) def __perform_key_handshake(self): - header = self.__generate_msl_header(is_key_request=True, is_handshake=True, compressionalgo="", encrypt=False) + esn = self.kodi_helper.get_esn() request = { 'entityauthdata': { 'scheme': 'NONE', 'authdata': { - 'identity': self.esn + 'identity': esn } }, 'headerdata': base64.standard_b64encode(header), 'signature': '', } - kodi_helper.log(msg='Key Handshake Request:') - kodi_helper.log(msg=json.dumps(request)) - + self.kodi_helper.log(msg='Key Handshake Request:') + self.kodi_helper.log(msg=json.dumps(request)) resp = self.session.post(self.endpoints['manifest'], json.dumps(request, sort_keys=True)) if resp.status_code == 200: resp = resp.json() if 'errordata' in resp: - kodi_helper.log(msg='Key Exchange failed') - kodi_helper.log(msg=base64.standard_b64decode(resp['errordata'])) + self.kodi_helper.log(msg='Key Exchange failed') + self.kodi_helper.log(msg=base64.standard_b64decode(resp['errordata'])) return False self.__parse_crypto_keys(json.JSONDecoder().decode(base64.standard_b64decode(resp['headerdata']))) else: - kodi_helper.log(msg='Key Exchange failed') - kodi_helper.log(msg=resp.text) + self.kodi_helper.log(msg='Key Exchange failed') + self.kodi_helper.log(msg=resp.text) def __parse_crypto_keys(self, headerdata): self.__set_master_token(headerdata['keyresponsedata']['mastertoken']) @@ -497,11 +583,27 @@ class MSL: self.handshake_performed = True def __load_msl_data(self): - msl_data = json.JSONDecoder().decode(self.load_file('msl_data.json')) + msl_data = json.JSONDecoder().decode(self.load_file(self.kodi_helper.msl_data_path, 'msl_data.json')) + #Check expire date of the token + master_token = json.JSONDecoder().decode(base64.standard_b64decode(msl_data['tokens']['mastertoken']['tokendata'])) + valid_until = datetime.utcfromtimestamp(int(master_token['expiration'])) + present = datetime.now() + difference = valid_until - present + difference = difference.total_seconds() / 60 / 60 + # If token expires in less then 10 hours or is expires renew it + if difference < 10: + self.__load_rsa_keys() + self.__perform_key_handshake() + return + self.__set_master_token(msl_data['tokens']['mastertoken']) self.encryption_key = base64.standard_b64decode(msl_data['encryption_key']) self.sign_key = base64.standard_b64decode(msl_data['sign_key']) + + def save_msl_data(self): + self.__save_msl_data() + def __save_msl_data(self): """ Saves the keys and tokens in json file @@ -515,50 +617,49 @@ class MSL: } } serialized_data = json.JSONEncoder().encode(data) - self.save_file('msl_data.json', serialized_data) + self.save_file(self.kodi_helper.msl_data_path, 'msl_data.json', serialized_data) def __set_master_token(self, master_token): self.mastertoken = master_token - self.sequence_number = json.JSONDecoder().decode(base64.standard_b64decode(master_token['tokendata']))[ - 'sequencenumber'] + self.sequence_number = json.JSONDecoder().decode(base64.standard_b64decode(master_token['tokendata']))['sequencenumber'] def __load_rsa_keys(self): - loaded_key = self.load_file('rsa_key.bin') + loaded_key = self.load_file(self.kodi_helper.msl_data_path, 'rsa_key.bin') self.rsa_key = RSA.importKey(loaded_key) def __save_rsa_keys(self): - kodi_helper.log(msg='Save RSA Keys') + self.kodi_helper.log(msg='Save RSA Keys') # Get the DER Base64 of the keys encrypted_key = self.rsa_key.exportKey() - self.save_file('rsa_key.bin', encrypted_key) + self.save_file(self.kodi_helper.msl_data_path, 'rsa_key.bin', encrypted_key) @staticmethod - def file_exists(filename): + def file_exists(msl_data_path, filename): """ Checks if a given file exists :param filename: The filename :return: True if so """ - return os.path.isfile(kodi_helper.msl_data_path + filename) + return os.path.isfile(msl_data_path + filename) @staticmethod - def save_file(filename, content): + def save_file(msl_data_path, filename, content): """ Saves the given content under given filename :param filename: The filename :param content: The content of the file """ - with open(kodi_helper.msl_data_path + filename, 'w') as file_: + with open(msl_data_path + filename, 'w') as file_: file_.write(content) file_.flush() @staticmethod - def load_file(filename): + def load_file(msl_data_path, filename): """ Loads the content of a given filename :param filename: The file to load :return: The content of the file """ - with open(kodi_helper.msl_data_path + filename) as file_: + with open(msl_data_path + filename) as file_: file_content = file_.read() return file_content