2 # -*- coding: utf-8 -*-
4 # Created on: 26.01.2017
12 from StringIO import StringIO
14 from datetime import datetime
19 from Cryptodome.PublicKey import RSA
20 from Cryptodome.Cipher import PKCS1_OAEP
21 from Cryptodome.Cipher import AES
22 from Cryptodome.Random import get_random_bytes
23 from Cryptodome.Hash import HMAC, SHA256
24 from Cryptodome.Util import Padding
25 import xml.etree.ElementTree as ET
27 pp = pprint.PrettyPrinter(indent=4)
29 def base64key_decode(payload):
36 raise ValueError('Invalid base64 string')
37 return base64.urlsafe_b64decode(payload.encode('utf-8'))
41 handshake_performed = False # Is a handshake already performed and the keys loaded
43 last_playback_context = ''
44 #esn = "NFCDCH-LX-CQE0NU6PA5714R25VPLXVU2A193T36"
45 #esn = "WWW-BROWSE-D7GW1G4NPXGR1F0X1H3EQGY3V1F5WE"
46 #esn = "NFCDIE-02-DCH84Q2EK3N6VFVQJ0NLRQ27498N0F"
47 current_message_id = 0
48 session = requests.session()
49 rndm = random.SystemRandom()
52 'manifest': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/manifest',
53 'license': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/license'
56 def __init__(self, kodi_helper):
58 The Constructor checks for already existing crypto Keys.
59 If they exist it will load the existing keys
61 self.kodi_helper = kodi_helper
63 os.mkdir(self.kodi_helper.msl_data_path)
67 if self.file_exists(self.kodi_helper.msl_data_path, 'msl_data.json'):
68 self.kodi_helper.log(msg='MSL Data exists. Use old Tokens.')
69 self.__load_msl_data()
70 self.handshake_performed = True
71 elif self.file_exists(self.kodi_helper.msl_data_path, 'rsa_key.bin'):
72 self.kodi_helper.log(msg='RSA Keys do already exist load old ones')
73 self.__load_rsa_keys()
74 if self.kodi_helper.get_esn():
75 self.__perform_key_handshake()
77 self.kodi_helper.log(msg='Create new RSA Keys')
78 # Create new Key Pair and save
79 self.rsa_key = RSA.generate(2048)
80 self.__save_rsa_keys()
81 if self.kodi_helper.get_esn():
82 self.__perform_key_handshake()
84 def perform_key_handshake(self):
85 self.__perform_key_handshake()
87 def load_manifest(self, viewable_id):
89 Loads the manifets for the given viewable_id and returns a mpd-XML-Manifest
90 :param viewable_id: The id of of the viewable
91 :return: MPD XML Manifest or False if no success
93 manifest_request_data = {
95 'lookupType': 'PREPARE',
96 'viewableIds': [viewable_id],
98 "playready-h264bpl30-dash",
99 "playready-h264mpl30-dash",
100 "playready-h264mpl31-dash",
101 "playready-h264mpl40-dash",
102 # "hevc-main-L30-dash-cenc",
103 # "hevc-main-L31-dash-cenc",
104 # "hevc-main-L40-dash-cenc",
105 # "hevc-main-L41-dash-cenc",
106 # "hevc-main-L50-dash-cenc",
107 # "hevc-main-L51-dash-cenc",
108 # "hevc-main10-L30-dash-cenc",
109 # "hevc-main10-L31-dash-cenc",
110 # "hevc-main10-L40-dash-cenc",
111 # "hevc-main10-L41-dash-cenc",
112 # "hevc-main10-L50-dash-cenc",
113 # "hevc-main10-L51-dash-cenc",
114 # "hevc-main10-L30-dash-cenc-prk",
115 # "hevc-main10-L31-dash-cenc-prk",
116 # "hevc-main10-L40-dash-cenc-prk",
117 # "hevc-main10-L41-dash-cenc-prk",
118 # "hevc-main-L30-L31-dash-cenc-tl",
119 # "hevc-main-L31-L40-dash-cenc-tl",
120 # "hevc-main-L40-L41-dash-cenc-tl",
121 # "hevc-main-L50-L51-dash-cenc-tl",
122 # "hevc-main10-L30-L31-dash-cenc-tl",
123 # "hevc-main10-L31-L40-dash-cenc-tl",
124 # "hevc-main10-L40-L41-dash-cenc-tl",
125 # "hevc-main10-L50-L51-dash-cenc-tl",
126 # "hevc-dv-main10-L30-dash-cenc",
127 # "hevc-dv-main10-L31-dash-cenc",
128 # "hevc-dv-main10-L40-dash-cenc",
129 # "hevc-dv-main10-L41-dash-cenc",
130 # "hevc-dv-main10-L50-dash-cenc",
131 # "hevc-dv-main10-L51-dash-cenc",
132 # "hevc-dv5-main10-L30-dash-cenc-prk",
133 # "hevc-dv5-main10-L31-dash-cenc-prk",
134 # "hevc-dv5-main10-L40-dash-cenc-prk",
135 # "hevc-dv5-main10-L41-dash-cenc-prk",
136 # "hevc-dv5-main10-L50-dash-cenc-prk",
137 # "hevc-dv5-main10-L51-dash-cenc-prk",
138 # "hevc-hdr-main10-L30-dash-cenc",
139 # "hevc-hdr-main10-L31-dash-cenc",
140 # "hevc-hdr-main10-L40-dash-cenc",
141 # "hevc-hdr-main10-L41-dash-cenc",
142 # "hevc-hdr-main10-L50-dash-cenc",
143 # "hevc-hdr-main10-L51-dash-cenc",
144 # "hevc-hdr-main10-L30-dash-cenc-prk",
145 # "hevc-hdr-main10-L31-dash-cenc-prk",
146 # "hevc-hdr-main10-L40-dash-cenc-prk",
147 # "hevc-hdr-main10-L41-dash-cenc-prk",
148 # "hevc-hdr-main10-L50-dash-cenc-prk",
149 # "hevc-hdr-main10-L51-dash-cenc-prk"
151 # 'playready-h264mpl30-dash',
152 #'playready-h264mpl31-dash',
153 #'playready-h264mpl40-dash',
154 #'hevc-main10-L41-dash-cenc',
155 #'hevc-main10-L50-dash-cenc',
156 #'hevc-main10-L51-dash-cenc',
172 'drmSystem': 'widevine',
173 'appId': '14673889385265',
175 'pinCapableClient': False,
176 'uiplaycontext': 'null'
178 'sessionId': '14673889385265',
180 'flavor': 'PRE_FETCH',
182 'supportPreviewContent': True,
183 'forceClearStreams': False,
184 'languages': ['de-DE'],
185 'clientVersion': '4.0004.899.011',
189 # Check if dolby sound is enabled and add to profles
190 if self.kodi_helper.get_dolby_setting():
191 manifest_request_data['profiles'].append('ddplus-2.0-dash')
192 manifest_request_data['profiles'].append('ddplus-5.1-dash')
194 request_data = self.__generate_msl_request_data(manifest_request_data)
195 resp = self.session.post(self.endpoints['manifest'], request_data)
198 # if the json() does not fail we have an error because the manifest response is a chuncked json response
200 self.kodi_helper.log(msg='Error getting Manifest: '+resp.text)
203 # json() failed so parse the chunked response
204 self.kodi_helper.log(msg='Got chunked Manifest Response: ' + resp.text)
205 resp = self.__parse_chunked_msl_response(resp.text)
206 self.kodi_helper.log(msg='Parsed chunked Response: ' + json.dumps(resp))
207 data = self.__decrypt_payload_chunk(resp['payloads'][0])
208 return self.__tranform_to_dash(data)
210 def get_license(self, challenge, sid):
212 Requests and returns a license for the given challenge and sid
213 :param challenge: The base64 encoded challenge
214 :param sid: The sid paired to the challengew
215 :return: Base64 representation of the license key or False if no success
217 license_request_data = {
219 'licenseType': 'STANDARD',
220 'clientVersion': '4.0004.899.011',
221 'uiVersion': 'akira',
222 'languages': ['de-DE'],
223 'playbackContextId': self.last_playback_context,
224 'drmContextIds': [self.last_drm_context],
226 'dataBase64': challenge,
229 'clientTime': int(time.time()),
230 'xid': int((int(time.time()) + 0.1612) * 1000)
233 request_data = self.__generate_msl_request_data(license_request_data)
235 resp = self.session.post(self.endpoints['license'], request_data)
238 # If is valid json the request for the licnese failed
240 self.kodi_helper.log(msg='Error getting license: '+resp.text)
243 # json() failed so we have a chunked json response
244 resp = self.__parse_chunked_msl_response(resp.text)
245 data = self.__decrypt_payload_chunk(resp['payloads'][0])
246 if data['success'] is True:
247 return data['result']['licenses'][0]['data']
249 self.kodi_helper.log(msg='Error getting license: ' + json.dumps(data))
252 def __decrypt_payload_chunk(self, payloadchunk):
253 payloadchunk = json.JSONDecoder().decode(payloadchunk)
254 encryption_envelope = json.JSONDecoder().decode(base64.standard_b64decode(payloadchunk['payload']))
256 cipher = AES.new(self.encryption_key, AES.MODE_CBC, base64.standard_b64decode(encryption_envelope['iv']))
257 plaintext = cipher.decrypt(base64.standard_b64decode(encryption_envelope['ciphertext']))
258 # unpad the plaintext
259 plaintext = json.JSONDecoder().decode(Padding.unpad(plaintext, 16))
260 data = plaintext['data']
262 # uncompress data if compressed
263 if plaintext['compressionalgo'] == 'GZIP':
264 data = zlib.decompress(base64.standard_b64decode(data), 16 + zlib.MAX_WBITS)
266 data = base64.standard_b64decode(data)
268 data = json.JSONDecoder().decode(data)[1]['payload']['data']
269 data = base64.standard_b64decode(data)
270 return json.JSONDecoder().decode(data)
273 def __tranform_to_dash(self, manifest):
275 self.save_file(self.kodi_helper.msl_data_path, 'manifest.json', json.dumps(manifest))
276 manifest = manifest['result']['viewables'][0]
278 self.last_playback_context = manifest['playbackContextId']
279 self.last_drm_context = manifest['drmContextId']
283 if 'psshb64' in manifest:
284 if len(manifest['psshb64']) >= 1:
285 pssh = manifest['psshb64'][0]
287 seconds = manifest['runtime']/1000
288 init_length = seconds / 2 * 12 + 20*1000
289 duration = "PT"+str(seconds)+".00S"
291 root = ET.Element('MPD')
292 root.attrib['xmlns'] = 'urn:mpeg:dash:schema:mpd:2011'
293 root.attrib['xmlns:cenc'] = 'urn:mpeg:cenc:2013'
294 root.attrib['mediaPresentationDuration'] = duration
296 period = ET.SubElement(root, 'Period', start='PT0S', duration=duration)
298 # One Adaption Set for Video
299 for video_track in manifest['videoTracks']:
300 video_adaption_set = ET.SubElement(period, 'AdaptationSet', mimeType='video/mp4', contentType="video")
302 protection = ET.SubElement(video_adaption_set, 'ContentProtection',
303 schemeIdUri='urn:uuid:EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED')
305 ET.SubElement(protection, 'cenc:pssh').text = pssh
307 for downloadable in video_track['downloadables']:
310 if 'hevc' in downloadable['contentProfile']:
313 hdcp_versions = '0.0'
314 for hdcp in downloadable['hdcpVersions']:
318 rep = ET.SubElement(video_adaption_set, 'Representation',
319 width=str(downloadable['width']),
320 height=str(downloadable['height']),
321 bandwidth=str(downloadable['bitrate']*1024),
323 nflxContentProfile=str(downloadable['contentProfile']),
325 mimeType='video/mp4')
328 ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
329 # Init an Segment block
330 segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-"+str(init_length), indexRangeExact="true")
331 ET.SubElement(segment_base, 'Initialization', range='0-'+str(init_length))
335 # Multiple Adaption Set for audio
336 for audio_track in manifest['audioTracks']:
337 audio_adaption_set = ET.SubElement(period, 'AdaptationSet',
338 lang=audio_track['bcp47'],
340 mimeType='audio/mp4')
341 for downloadable in audio_track['downloadables']:
344 if downloadable['contentProfile'] == 'ddplus-2.0-dash' or downloadable['contentProfile'] == 'ddplus-5.1-dash':
346 print "codec is: " + codec
347 rep = ET.SubElement(audio_adaption_set, 'Representation',
349 bandwidth=str(downloadable['bitrate']*1024),
350 mimeType='audio/mp4')
353 ET.SubElement(rep, 'AudioChannelConfiguration',
354 schemeIdUri='urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
355 value=str(audio_track['channelsCount']))
358 ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
360 segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-"+str(init_length), indexRangeExact="true")
361 ET.SubElement(segment_base, 'Initialization', range='0-'+str(init_length))
363 # Multiple Adaption Sets for subtiles
364 for text_track in manifest['textTracks']:
365 if 'downloadables' not in text_track or text_track['downloadables'] is None:
367 subtiles_adaption_set = ET.SubElement(period, 'AdaptationSet',
368 lang=text_track['bcp47'],
371 mimeType='application/ttml+xml')
372 for downloadable in text_track['downloadables']:
373 rep = ET.SubElement(subtiles_adaption_set, 'Representation',
374 nflxProfile=downloadable['contentProfile']
376 ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
379 xml = ET.tostring(root, encoding='utf-8', method='xml')
380 xml = xml.replace('\n', '').replace('\r', '')
383 def __get_base_url(self, urls):
387 def __parse_chunked_msl_response(self, message):
395 while i < len(message):
396 if message[i] == '{':
397 opencount = opencount + 1
398 if message[i] == '}':
399 closecount = closecount + 1
400 if opencount == closecount:
405 payloads.append(message[old_end:i + 1])
413 def __generate_msl_request_data(self, data):
414 self.__load_msl_data()
415 header_encryption_envelope = self.__encrypt(self.__generate_msl_header())
417 'headerdata': base64.standard_b64encode(header_encryption_envelope),
418 'signature': self.__sign(header_encryption_envelope),
419 'mastertoken': self.mastertoken,
422 # Serialize the given Data
423 serialized_data = json.dumps(data)
424 serialized_data = serialized_data.replace('"', '\\"')
425 serialized_data = '[{},{"headers":{},"path":"/cbp/cadmium-13","payload":{"data":"' + serialized_data + '"},"query":""}]\n'
427 compressed_data = self.__compress_data(serialized_data)
429 # Create FIRST Payload Chunks
431 "messageid": self.current_message_id,
432 "data": compressed_data,
433 "compressionalgo": "GZIP",
437 first_payload_encryption_envelope = self.__encrypt(json.dumps(first_payload))
438 first_payload_chunk = {
439 'payload': base64.standard_b64encode(first_payload_encryption_envelope),
440 'signature': self.__sign(first_payload_encryption_envelope),
443 request_data = json.dumps(header) + json.dumps(first_payload_chunk)
448 def __compress_data(self, data):
451 with gzip.GzipFile(fileobj=out, mode="w") as f:
453 return base64.standard_b64encode(out.getvalue())
456 def __generate_msl_header(self, is_handshake=False, is_key_request=False, compressionalgo="GZIP", encrypt=True):
458 Function that generates a MSL header dict
459 :return: The base64 encoded JSON String of the header
461 self.current_message_id = self.rndm.randint(0, pow(2, 52))
462 esn = self.kodi_helper.get_esn()
466 'handshake': is_handshake,
467 'nonreplayable': False,
469 'languages': ["en-US"],
470 'compressionalgos': []
472 'recipient': 'Netflix',
474 'messageid': self.current_message_id,
475 'timestamp': 1467733923
478 # Add compression algo if not empty
479 if compressionalgo is not "":
480 header_data['capabilities']['compressionalgos'].append(compressionalgo)
482 # If this is a keyrequest act diffrent then other requests
484 public_key = base64.standard_b64encode(self.rsa_key.publickey().exportKey(format='DER'))
485 header_data['keyrequestdata'] = [{
486 'scheme': 'ASYMMETRIC_WRAPPED',
488 'publickey': public_key,
489 'mechanism': 'JWK_RSA',
490 'keypairid': 'superKeyPair'
494 if 'usertoken' in self.tokens:
497 account = self.kodi_helper.get_credentials()
498 # Auth via email and password
499 header_data['userauthdata'] = {
500 'scheme': 'EMAIL_PASSWORD',
502 'email': account['email'],
503 'password': account['password']
507 return json.dumps(header_data)
511 def __encrypt(self, plaintext):
513 Encrypt the given Plaintext with the encryption key
515 :return: Serialized JSON String of the encryption Envelope
517 esn = self.kodi_helper.get_esn()
519 iv = get_random_bytes(16)
520 encryption_envelope = {
522 'keyid': esn + '_' + str(self.sequence_number),
524 'iv': base64.standard_b64encode(iv)
527 plaintext = Padding.pad(plaintext, 16)
529 cipher = AES.new(self.encryption_key, AES.MODE_CBC, iv)
530 ciphertext = cipher.encrypt(plaintext)
531 encryption_envelope['ciphertext'] = base64.standard_b64encode(ciphertext)
532 return json.dumps(encryption_envelope)
535 def __sign(self, text):
537 Calculates the HMAC signature for the given text with the current sign key and SHA256
539 :return: Base64 encoded signature
541 signature = HMAC.new(self.sign_key, text, SHA256).digest()
542 return base64.standard_b64encode(signature)
545 def __perform_key_handshake(self):
546 header = self.__generate_msl_header(is_key_request=True, is_handshake=True, compressionalgo="", encrypt=False)
547 esn = self.kodi_helper.get_esn()
556 'headerdata': base64.standard_b64encode(header),
559 self.kodi_helper.log(msg='Key Handshake Request:')
560 self.kodi_helper.log(msg=json.dumps(request))
562 resp = self.session.post(self.endpoints['manifest'], json.dumps(request, sort_keys=True))
563 if resp.status_code == 200:
565 if 'errordata' in resp:
566 self.kodi_helper.log(msg='Key Exchange failed')
567 self.kodi_helper.log(msg=base64.standard_b64decode(resp['errordata']))
569 self.__parse_crypto_keys(json.JSONDecoder().decode(base64.standard_b64decode(resp['headerdata'])))
571 self.kodi_helper.log(msg='Key Exchange failed')
572 self.kodi_helper.log(msg=resp.text)
574 def __parse_crypto_keys(self, headerdata):
575 self.__set_master_token(headerdata['keyresponsedata']['mastertoken'])
577 encrypted_encryption_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['encryptionkey'])
578 encrypted_sign_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['hmackey'])
579 cipher_rsa = PKCS1_OAEP.new(self.rsa_key)
581 # Decrypt encryption key
582 encryption_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_encryption_key))
583 self.encryption_key = base64key_decode(encryption_key_data['k'])
586 sign_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_sign_key))
587 self.sign_key = base64key_decode(sign_key_data['k'])
589 self.__save_msl_data()
590 self.handshake_performed = True
592 def __load_msl_data(self):
593 msl_data = json.JSONDecoder().decode(self.load_file(self.kodi_helper.msl_data_path, 'msl_data.json'))
594 #Check expire date of the token
595 master_token = json.JSONDecoder().decode(base64.standard_b64decode(msl_data['tokens']['mastertoken']['tokendata']))
596 valid_until = datetime.utcfromtimestamp(int(master_token['expiration']))
597 present = datetime.now()
598 difference = valid_until - present
599 difference = difference.total_seconds() / 60 / 60
600 # If token expires in less then 10 hours or is expires renew it
602 self.__load_rsa_keys()
603 self.__perform_key_handshake()
606 self.__set_master_token(msl_data['tokens']['mastertoken'])
607 self.encryption_key = base64.standard_b64decode(msl_data['encryption_key'])
608 self.sign_key = base64.standard_b64decode(msl_data['sign_key'])
611 def save_msl_data(self):
612 self.__save_msl_data()
614 def __save_msl_data(self):
616 Saves the keys and tokens in json file
620 "encryption_key": base64.standard_b64encode(self.encryption_key),
621 'sign_key': base64.standard_b64encode(self.sign_key),
623 'mastertoken': self.mastertoken
626 serialized_data = json.JSONEncoder().encode(data)
627 self.save_file(self.kodi_helper.msl_data_path, 'msl_data.json', serialized_data)
629 def __set_master_token(self, master_token):
630 self.mastertoken = master_token
631 self.sequence_number = json.JSONDecoder().decode(base64.standard_b64decode(master_token['tokendata']))['sequencenumber']
633 def __load_rsa_keys(self):
634 loaded_key = self.load_file(self.kodi_helper.msl_data_path, 'rsa_key.bin')
635 self.rsa_key = RSA.importKey(loaded_key)
637 def __save_rsa_keys(self):
638 self.kodi_helper.log(msg='Save RSA Keys')
639 # Get the DER Base64 of the keys
640 encrypted_key = self.rsa_key.exportKey()
641 self.save_file(self.kodi_helper.msl_data_path, 'rsa_key.bin', encrypted_key)
644 def file_exists(msl_data_path, filename):
646 Checks if a given file exists
647 :param filename: The filename
650 return os.path.isfile(msl_data_path + filename)
653 def save_file(msl_data_path, filename, content):
655 Saves the given content under given filename
656 :param filename: The filename
657 :param content: The content of the file
659 with open(msl_data_path + filename, 'w') as file_:
664 def load_file(msl_data_path, filename):
666 Loads the content of a given filename
667 :param filename: The file to load
668 :return: The content of the file
670 with open(msl_data_path + filename) as file_:
671 file_content = file_.read()