7 from StringIO import StringIO
12 from Cryptodome.PublicKey import RSA
13 from Cryptodome.Cipher import PKCS1_OAEP
14 from Cryptodome.Cipher import AES
15 from Cryptodome.Random import get_random_bytes
16 from Cryptodome.Hash import HMAC, SHA256
17 from Cryptodome.Util import Padding
18 import xml.etree.ElementTree as ET
20 pp = pprint.PrettyPrinter(indent=4)
22 def base64key_decode(payload):
29 raise ValueError('Invalid base64 string')
30 return base64.urlsafe_b64decode(payload.encode('utf-8'))
34 handshake_performed = False # Is a handshake already performed and the keys loaded
36 last_playback_context = ''
37 #esn = "NFCDCH-LX-CQE0NU6PA5714R25VPLXVU2A193T36"
38 esn = "WWW-BROWSE-D7GW1G4NPXGR1F0X1H3EQGY3V1F5WE"
39 #esn = "NFCDIE-02-DCH84Q2EK3N6VFVQJ0NLRQ27498N0F"
40 current_message_id = 0
41 session = requests.session()
42 rndm = random.SystemRandom()
45 'manifest': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/manifest',
46 'license': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/license'
49 def __init__(self, email, password, kodi_helper):
51 The Constructor checks for already existing crypto Keys.
52 If they exist it will load the existing keys
55 self.password = password
56 self.kodi_helper = kodi_helper
58 os.mkdir(self.kodi_helper.msl_data_path)
62 if self.file_exists(self.kodi_helper.msl_data_path, 'msl_data.json'):
63 self.kodi_helper.log(msg='MSL Data exists. Use old Tokens.')
64 self.__load_msl_data()
65 self.handshake_performed = True
66 elif self.file_exists(self.kodi_helper.msl_data_path, 'rsa_key.bin'):
67 self.kodi_helper.log(msg='RSA Keys do already exist load old ones')
68 self.__load_rsa_keys()
69 self.__perform_key_handshake()
71 self.kodi_helper.log(msg='Create new RSA Keys')
72 # Create new Key Pair and save
73 self.rsa_key = RSA.generate(2048)
74 self.__save_rsa_keys()
75 self.__perform_key_handshake()
77 def load_manifest(self, viewable_id):
79 Loads the manifets for the given viewable_id and returns a mpd-XML-Manifest
80 :param viewable_id: The id of of the viewable
81 :return: MPD XML Manifest or False if no success
83 manifest_request_data = {
85 'lookupType': 'PREPARE',
86 'viewableIds': [viewable_id],
88 'playready-h264mpl30-dash',
89 'playready-h264mpl31-dash',
90 'playready-h264mpl40-dash',
98 'drmSystem': 'widevine',
99 'appId': '14673889385265',
101 'pinCapableClient': False,
102 'uiplaycontext': 'null'
104 'sessionId': '14673889385265',
106 'flavor': 'PRE_FETCH',
108 'supportPreviewContent': True,
109 'forceClearStreams': False,
110 'languages': ['de-DE'],
111 'clientVersion': '4.0004.899.011',
114 request_data = self.__generate_msl_request_data(manifest_request_data)
115 resp = self.session.post(self.endpoints['manifest'], request_data)
118 # if the json() does not fail we have an error because the manifest response is a chuncked json response
120 self.kodi_helper.log(msg='Error getting Manifest: '+resp.text)
123 # json() failed so parse the chunked response
124 self.kodi_helper.log(msg='Got chunked Manifest Response: ' + resp.text)
125 resp = self.__parse_chunked_msl_response(resp.text)
126 self.kodi_helper.log(msg='Parsed chunked Response: ' + json.dumps(resp))
127 data = self.__decrypt_payload_chunk(resp['payloads'][0])
128 return self.__tranform_to_dash(data)
130 def get_license(self, challenge, sid):
132 Requests and returns a license for the given challenge and sid
133 :param challenge: The base64 encoded challenge
134 :param sid: The sid paired to the challengew
135 :return: Base64 representation of the license key or False if no success
137 license_request_data = {
139 'licenseType': 'STANDARD',
140 'clientVersion': '4.0004.899.011',
141 'uiVersion': 'akira',
142 'languages': ['de-DE'],
143 'playbackContextId': self.last_playback_context,
144 'drmContextIds': [self.last_drm_context],
146 'dataBase64': challenge,
149 'clientTime': int(time.time()),
150 'xid': int((int(time.time()) + 0.1612) * 1000)
153 request_data = self.__generate_msl_request_data(license_request_data)
155 resp = self.session.post(self.endpoints['license'], request_data)
158 # If is valid json the request for the licnese failed
160 self.kodi_helper.log(msg='Error getting license: '+resp.text)
163 # json() failed so we have a chunked json response
164 resp = self.__parse_chunked_msl_response(resp.text)
165 data = self.__decrypt_payload_chunk(resp['payloads'][0])
166 if data['success'] is True:
167 return data['result']['licenses'][0]['data']
169 self.kodi_helper.log(msg='Error getting license: ' + json.dumps(data))
172 def __decrypt_payload_chunk(self, payloadchunk):
173 payloadchunk = json.JSONDecoder().decode(payloadchunk)
174 encryption_envelope = json.JSONDecoder().decode(base64.standard_b64decode(payloadchunk['payload']))
176 cipher = AES.new(self.encryption_key, AES.MODE_CBC, base64.standard_b64decode(encryption_envelope['iv']))
177 plaintext = cipher.decrypt(base64.standard_b64decode(encryption_envelope['ciphertext']))
178 # unpad the plaintext
179 plaintext = json.JSONDecoder().decode(Padding.unpad(plaintext, 16))
180 data = plaintext['data']
182 # uncompress data if compressed
183 if plaintext['compressionalgo'] == 'GZIP':
184 data = zlib.decompress(base64.standard_b64decode(data), 16 + zlib.MAX_WBITS)
186 data = base64.standard_b64decode(data)
188 data = json.JSONDecoder().decode(data)[1]['payload']['data']
189 data = base64.standard_b64decode(data)
190 return json.JSONDecoder().decode(data)
193 def __tranform_to_dash(self, manifest):
195 self.save_file(self.kodi_helper.msl_data_path, 'manifest.json', json.dumps(manifest))
196 manifest = manifest['result']['viewables'][0]
198 self.last_playback_context = manifest['playbackContextId']
199 self.last_drm_context = manifest['drmContextId']
203 if 'psshb64' in manifest:
204 if len(manifest['psshb64']) >= 1:
205 pssh = manifest['psshb64'][0]
207 seconds = manifest['runtime']/1000
208 duration = "PT"+str(seconds)+".00S"
210 root = ET.Element('MPD')
211 root.attrib['xmlns'] = 'urn:mpeg:dash:schema:mpd:2011'
212 root.attrib['xmlns:cenc'] = 'urn:mpeg:cenc:2013'
213 root.attrib['mediaPresentationDuration'] = duration
215 period = ET.SubElement(root, 'Period', start='PT0S', duration=duration)
217 # One Adaption Set for Video
218 for video_track in manifest['videoTracks']:
219 video_adaption_set = ET.SubElement(period, 'AdaptationSet', mimeType='video/mp4', contentType="video")
221 protection = ET.SubElement(video_adaption_set, 'ContentProtection',
222 schemeIdUri='urn:uuid:EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED')
224 ET.SubElement(protection, 'cenc:pssh').text = pssh
226 for downloadable in video_track['downloadables']:
227 rep = ET.SubElement(video_adaption_set, 'Representation',
228 width=str(downloadable['width']),
229 height=str(downloadable['height']),
230 bandwidth=str(downloadable['bitrate']*1024),
232 mimeType='video/mp4')
235 ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
236 # Init an Segment block
237 segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true")
238 ET.SubElement(segment_base, 'Initialization', range='0-60000')
242 # Multiple Adaption Set for audio
243 for audio_track in manifest['audioTracks']:
244 audio_adaption_set = ET.SubElement(period, 'AdaptationSet',
245 lang=audio_track['bcp47'],
247 mimeType='audio/mp4')
248 for downloadable in audio_track['downloadables']:
249 rep = ET.SubElement(audio_adaption_set, 'Representation',
251 bandwidth=str(downloadable['bitrate']*1024),
252 mimeType='audio/mp4')
255 ET.SubElement(rep, 'AudioChannelConfiguration',
256 schemeIdUri='urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
257 value=str(audio_track['channelsCount']))
260 ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
262 segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true")
263 ET.SubElement(segment_base, 'Initialization', range='0-60000')
266 xml = ET.tostring(root, encoding='utf-8', method='xml')
267 xml = xml.replace('\n', '').replace('\r', '')
270 def __get_base_url(self, urls):
274 def __parse_chunked_msl_response(self, message):
282 while i < len(message):
283 if message[i] == '{':
284 opencount = opencount + 1
285 if message[i] == '}':
286 closecount = closecount + 1
287 if opencount == closecount:
292 payloads.append(message[old_end:i + 1])
300 def __generate_msl_request_data(self, data):
301 header_encryption_envelope = self.__encrypt(self.__generate_msl_header())
303 'headerdata': base64.standard_b64encode(header_encryption_envelope),
304 'signature': self.__sign(header_encryption_envelope),
305 'mastertoken': self.mastertoken,
308 # Serialize the given Data
309 serialized_data = json.dumps(data)
310 serialized_data = serialized_data.replace('"', '\\"')
311 serialized_data = '[{},{"headers":{},"path":"/cbp/cadmium-11","payload":{"data":"' + serialized_data + '"},"query":""}]\n'
313 compressed_data = self.__compress_data(serialized_data)
315 # Create FIRST Payload Chunks
317 "messageid": self.current_message_id,
318 "data": compressed_data,
319 "compressionalgo": "GZIP",
323 first_payload_encryption_envelope = self.__encrypt(json.dumps(first_payload))
324 first_payload_chunk = {
325 'payload': base64.standard_b64encode(first_payload_encryption_envelope),
326 'signature': self.__sign(first_payload_encryption_envelope),
329 request_data = json.dumps(header) + json.dumps(first_payload_chunk)
334 def __compress_data(self, data):
337 with gzip.GzipFile(fileobj=out, mode="w") as f:
339 return base64.standard_b64encode(out.getvalue())
342 def __generate_msl_header(self, is_handshake=False, is_key_request=False, compressionalgo="GZIP", encrypt=True):
344 Function that generates a MSL header dict
345 :return: The base64 encoded JSON String of the header
347 self.current_message_id = self.rndm.randint(0, pow(2, 52))
351 'handshake': is_handshake,
352 'nonreplayable': False,
354 'languages': ["en-US"],
355 'compressionalgos': []
357 'recipient': 'Netflix',
359 'messageid': self.current_message_id,
360 'timestamp': 1467733923
363 # Add compression algo if not empty
364 if compressionalgo is not "":
365 header_data['capabilities']['compressionalgos'].append(compressionalgo)
367 # If this is a keyrequest act diffrent then other requests
369 public_key = base64.standard_b64encode(self.rsa_key.publickey().exportKey(format='DER'))
370 header_data['keyrequestdata'] = [{
371 'scheme': 'ASYMMETRIC_WRAPPED',
373 'publickey': public_key,
374 'mechanism': 'JWK_RSA',
375 'keypairid': 'superKeyPair'
379 if 'usertoken' in self.tokens:
382 # Auth via email and password
383 header_data['userauthdata'] = {
384 'scheme': 'EMAIL_PASSWORD',
387 'password': self.password
391 return json.dumps(header_data)
395 def __encrypt(self, plaintext):
397 Encrypt the given Plaintext with the encryption key
399 :return: Serialized JSON String of the encryption Envelope
401 iv = get_random_bytes(16)
402 encryption_envelope = {
404 'keyid': self.esn + '_' + str(self.sequence_number),
406 'iv': base64.standard_b64encode(iv)
409 plaintext = Padding.pad(plaintext, 16)
411 cipher = AES.new(self.encryption_key, AES.MODE_CBC, iv)
412 ciphertext = cipher.encrypt(plaintext)
413 encryption_envelope['ciphertext'] = base64.standard_b64encode(ciphertext)
414 return json.dumps(encryption_envelope)
417 def __sign(self, text):
419 Calculates the HMAC signature for the given text with the current sign key and SHA256
421 :return: Base64 encoded signature
423 signature = HMAC.new(self.sign_key, text, SHA256).digest()
424 return base64.standard_b64encode(signature)
427 def __perform_key_handshake(self):
428 header = self.__generate_msl_header(is_key_request=True, is_handshake=True, compressionalgo="", encrypt=False)
436 'headerdata': base64.standard_b64encode(header),
439 self.kodi_helper.log(msg='Key Handshake Request:')
440 self.kodi_helper.log(msg=json.dumps(request))
442 resp = self.session.post(self.endpoints['manifest'], json.dumps(request, sort_keys=True))
443 if resp.status_code == 200:
445 if 'errordata' in resp:
446 self.kodi_helper.log(msg='Key Exchange failed')
447 self.kodi_helper.log(msg=base64.standard_b64decode(resp['errordata']))
449 self.__parse_crypto_keys(json.JSONDecoder().decode(base64.standard_b64decode(resp['headerdata'])))
451 self.kodi_helper.log(msg='Key Exchange failed')
452 self.kodi_helper.log(msg=resp.text)
454 def __parse_crypto_keys(self, headerdata):
455 self.__set_master_token(headerdata['keyresponsedata']['mastertoken'])
457 encrypted_encryption_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['encryptionkey'])
458 encrypted_sign_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['hmackey'])
459 cipher_rsa = PKCS1_OAEP.new(self.rsa_key)
461 # Decrypt encryption key
462 encryption_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_encryption_key))
463 self.encryption_key = base64key_decode(encryption_key_data['k'])
466 sign_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_sign_key))
467 self.sign_key = base64key_decode(sign_key_data['k'])
469 self.__save_msl_data()
470 self.handshake_performed = True
472 def __load_msl_data(self):
473 msl_data = json.JSONDecoder().decode(self.load_file(self.kodi_helper.msl_data_path, 'msl_data.json'))
474 self.__set_master_token(msl_data['tokens']['mastertoken'])
475 self.encryption_key = base64.standard_b64decode(msl_data['encryption_key'])
476 self.sign_key = base64.standard_b64decode(msl_data['sign_key'])
478 def __save_msl_data(self):
480 Saves the keys and tokens in json file
484 "encryption_key": base64.standard_b64encode(self.encryption_key),
485 'sign_key': base64.standard_b64encode(self.sign_key),
487 'mastertoken': self.mastertoken
490 serialized_data = json.JSONEncoder().encode(data)
491 self.save_file(self.kodi_helper.msl_data_path, 'msl_data.json', serialized_data)
493 def __set_master_token(self, master_token):
494 self.mastertoken = master_token
495 self.sequence_number = json.JSONDecoder().decode(base64.standard_b64decode(master_token['tokendata']))['sequencenumber']
497 def __load_rsa_keys(self):
498 loaded_key = self.load_file(self.kodi_helper.msl_data_path, 'rsa_key.bin')
499 self.rsa_key = RSA.importKey(loaded_key)
501 def __save_rsa_keys(self):
502 self.kodi_helper.log(msg='Save RSA Keys')
503 # Get the DER Base64 of the keys
504 encrypted_key = self.rsa_key.exportKey()
505 self.save_file(self.kodi_helper.msl_data_path, 'rsa_key.bin', encrypted_key)
508 def file_exists(msl_data_path, filename):
510 Checks if a given file exists
511 :param filename: The filename
514 return os.path.isfile(msl_data_path + filename)
517 def save_file(msl_data_path, filename, content):
519 Saves the given content under given filename
520 :param filename: The filename
521 :param content: The content of the file
523 with open(msl_data_path + filename, 'w') as file_:
528 def load_file(msl_data_path, filename):
530 Loads the content of a given filename
531 :param filename: The file to load
532 :return: The content of the file
534 with open(msl_data_path + filename) as file_:
535 file_content = file_.read()