Merge branch 'master' of https://github.com/asciidisco/plugin.video.netflix
[plugin.video.netflix.git] / resources / lib / MSL.py
1 import base64
2 import gzip
3 import json
4 import os
5 import pprint
6 import random
7 from StringIO import StringIO
8 import requests
9 import zlib
10
11 import time
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 Crypto.Hash import HMAC, SHA256
17 from Cryptodome.Util import Padding
18 import xml.etree.ElementTree as ET
19
20 pp = pprint.PrettyPrinter(indent=4)
21
22 def base64key_decode(payload):
23     l = len(payload) % 4
24     if l == 2:
25         payload += '=='
26     elif l == 3:
27         payload += '='
28     elif l != 0:
29         raise ValueError('Invalid base64 string')
30     return base64.urlsafe_b64decode(payload.encode('utf-8'))
31
32
33 class MSL:
34     handshake_performed = False  # Is a handshake already performed and the keys loaded
35     last_drm_context = ''
36     last_playback_context = ''
37     #esn = "NFCDCH-LX-CQE0NU6PA5714R25VPLXVU2A193T36"
38     esn = "WWW-BROWSE-D7GW1G4NPXGR1F0X1H3EQGY3V1F5WE"
39     current_message_id = 0
40     session = requests.session()
41     rndm = random.SystemRandom()
42     tokens = []
43     endpoints = {
44         'manifest': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/manifest',
45         'license': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/license'
46     }
47
48     def __init__(self, email, password, kodi_helper):
49         """
50         The Constructor checks for already existing crypto Keys.
51         If they exist it will load the existing keys
52         """
53         self.email = email
54         self.password = password
55         self.kodi_helper = kodi_helper
56         try:
57             os.mkdir(self.kodi_helper.msl_data_path)
58         except OSError:
59             pass
60
61         if self.file_exists(self.kodi_helper.msl_data_path, 'msl_data.json'):
62             self.kodi_helper.log(msg='MSL Data exists. Use old Tokens.')
63             self.__load_msl_data()
64             self.handshake_performed = True
65         elif self.file_exists(self.kodi_helper.msl_data_path, 'rsa_key.bin'):
66             self.kodi_helper.log(msg='RSA Keys do already exist load old ones')
67             self.__load_rsa_keys()
68             self.__perform_key_handshake()
69         else:
70             self.kodi_helper.log(msg='Create new RSA Keys')
71             # Create new Key Pair and save
72             self.rsa_key = RSA.generate(2048)
73             self.__save_rsa_keys()
74             self.__perform_key_handshake()
75
76     def load_manifest(self, viewable_id):
77         """
78         Loads the manifets for the given viewable_id and returns a mpd-XML-Manifest
79         :param viewable_id: The id of of the viewable
80         :return: MPD XML Manifest or False if no success
81         """
82         manifest_request_data = {
83             'method': 'manifest',
84             'lookupType': 'PREPARE',
85             'viewableIds': [viewable_id],
86             'profiles': [
87                 'playready-h264mpl30-dash',
88                 'playready-h264mpl31-dash',
89                 'heaac-2-dash',
90                 'dfxp-ls-sdh',
91                 'simplesdh',
92                 'nflx-cmisc',
93                 'BIF240',
94                 'BIF320'
95             ],
96             'drmSystem': 'widevine',
97             'appId': '14673889385265',
98             'sessionParams': {
99                 'pinCapableClient': False,
100                 'uiplaycontext': 'null'
101             },
102             'sessionId': '14673889385265',
103             'trackId': 0,
104             'flavor': 'PRE_FETCH',
105             'secureUrls': False,
106             'supportPreviewContent': True,
107             'forceClearStreams': False,
108             'languages': ['de-DE'],
109             'clientVersion': '4.0004.899.011',
110             'uiVersion': 'akira'
111         }
112         request_data = self.__generate_msl_request_data(manifest_request_data)
113         resp = self.session.post(self.endpoints['manifest'], request_data)
114
115         try:
116             # if the json() does not fail we have an error because the manifest response is a chuncked json response
117             resp.json()
118             self.kodi_helper.log(msg='Error getting Manifest: '+resp.text)
119             return False
120         except ValueError:
121             # json() failed so parse the chunked response
122             self.kodi_helper.log(msg='Got chunked Manifest Response: ' + resp.text)
123             resp = self.__parse_chunked_msl_response(resp.text)
124             self.kodi_helper.log(msg='Parsed chunked Response: ' + json.dumps(resp))
125             data = self.__decrypt_payload_chunk(resp['payloads'][0])
126             return self.__tranform_to_dash(data)
127
128     def get_license(self, challenge, sid):
129         """
130         Requests and returns a license for the given challenge and sid
131         :param challenge: The base64 encoded challenge
132         :param sid: The sid paired to the challengew
133         :return: Base64 representation of the license key or False if no success
134         """
135         license_request_data = {
136             'method': 'license',
137             'licenseType': 'STANDARD',
138             'clientVersion': '4.0004.899.011',
139             'uiVersion': 'akira',
140             'languages': ['de-DE'],
141             'playbackContextId': self.last_playback_context,
142             'drmContextIds': [self.last_drm_context],
143             'challenges': [{
144                 'dataBase64': challenge,
145                 'sessionId': sid
146             }],
147             'clientTime': int(time.time()),
148             'xid': int((int(time.time()) + 0.1612) * 1000)
149
150         }
151         request_data = self.__generate_msl_request_data(license_request_data)
152
153         resp = self.session.post(self.endpoints['license'], request_data)
154
155         try:
156             # If is valid json the request for the licnese failed
157             resp.json()
158             self.kodi_helper.log(msg='Error getting license: '+resp.text)
159             return False
160         except ValueError:
161             # json() failed so we have a chunked json response
162             resp = self.__parse_chunked_msl_response(resp.text)
163             data = self.__decrypt_payload_chunk(resp['payloads'][0])
164             if data['success'] is True:
165                 return data['result']['licenses'][0]['data']
166             else:
167                 self.kodi_helper.log(msg='Error getting license: ' + json.dumps(data))
168                 return False
169
170     def __decrypt_payload_chunk(self, payloadchunk):
171         payloadchunk = json.JSONDecoder().decode(payloadchunk)
172         encryption_envelope = json.JSONDecoder().decode(base64.standard_b64decode(payloadchunk['payload']))
173         # Decrypt the text
174         cipher = AES.new(self.encryption_key, AES.MODE_CBC, base64.standard_b64decode(encryption_envelope['iv']))
175         plaintext = cipher.decrypt(base64.standard_b64decode(encryption_envelope['ciphertext']))
176         # unpad the plaintext
177         plaintext = json.JSONDecoder().decode(Padding.unpad(plaintext, 16))
178         data = plaintext['data']
179
180         # uncompress data if compressed
181         if plaintext['compressionalgo'] == 'GZIP':
182             data = zlib.decompress(base64.standard_b64decode(data), 16 + zlib.MAX_WBITS)
183         else:
184             data = base64.standard_b64decode(data)
185
186         data = json.JSONDecoder().decode(data)[1]['payload']['data']
187         data = base64.standard_b64decode(data)
188         return json.JSONDecoder().decode(data)
189
190
191     def __tranform_to_dash(self, manifest):
192
193         self.save_file(self.kodi_helper.msl_data_path, 'manifest.json', json.dumps(manifest))
194         manifest = manifest['result']['viewables'][0]
195
196         self.last_playback_context = manifest['playbackContextId']
197         self.last_drm_context = manifest['drmContextId']
198
199         #Check for pssh
200         pssh = ''
201         if 'psshb64' in manifest:
202             if len(manifest['psshb64']) >= 1:
203                 pssh = manifest['psshb64'][0]
204
205         seconds = manifest['runtime']/1000
206         duration = "PT"+str(seconds)+".00S"
207
208         root = ET.Element('MPD')
209         root.attrib['xmlns'] = 'urn:mpeg:dash:schema:mpd:2011'
210         root.attrib['xmlns:cenc'] = 'urn:mpeg:cenc:2013'
211         root.attrib['mediaPresentationDuration'] = duration
212
213         period = ET.SubElement(root, 'Period', start='PT0S', duration=duration)
214
215         # One Adaption Set for Video
216         for video_track in manifest['videoTracks']:
217             video_adaption_set = ET.SubElement(period, 'AdaptationSet', mimeType='video/mp4', contentType="video")
218             # Content Protection
219             protection = ET.SubElement(video_adaption_set, 'ContentProtection',
220                           schemeIdUri='urn:uuid:EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED')
221             if pssh is not '':
222                 ET.SubElement(protection, 'cenc:pssh').text = pssh
223
224             for downloadable in video_track['downloadables']:
225                 rep = ET.SubElement(video_adaption_set, 'Representation',
226                                     width=str(downloadable['width']),
227                                     height=str(downloadable['height']),
228                                     bandwidth=str(downloadable['bitrate']*1024),
229                                     codecs='h264',
230                                     mimeType='video/mp4')
231
232                 #BaseURL
233                 ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
234                 # Init an Segment block
235                 segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true")
236                 ET.SubElement(segment_base, 'Initialization', range='0-60000')
237
238
239
240         # Multiple Adaption Set for audio
241         for audio_track in manifest['audioTracks']:
242             audio_adaption_set = ET.SubElement(period, 'AdaptationSet',
243                                                lang=audio_track['bcp47'],
244                                                contentType='audio',
245                                                mimeType='audio/mp4')
246             for downloadable in audio_track['downloadables']:
247                 rep = ET.SubElement(audio_adaption_set, 'Representation',
248                                     codecs='aac',
249                                     bandwidth=str(downloadable['bitrate']*1024),
250                                     mimeType='audio/mp4')
251
252                 #AudioChannel Config
253                 ET.SubElement(rep, 'AudioChannelConfiguration',
254                               schemeIdUri='urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
255                               value=str(audio_track['channelsCount']))
256
257                 #BaseURL
258                 ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
259                 # Index range
260                 segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true")
261                 ET.SubElement(segment_base, 'Initialization', range='0-60000')
262
263
264         xml = ET.tostring(root, encoding='utf-8', method='xml')
265         xml = xml.replace('\n', '').replace('\r', '')
266         return xml
267
268     def __get_base_url(self, urls):
269         for key in urls:
270             return urls[key]
271
272     def __parse_chunked_msl_response(self, message):
273         i = 0
274         opencount = 0
275         closecount = 0
276         header = ""
277         payloads = []
278         old_end = 0
279
280         while i < len(message):
281             if message[i] == '{':
282                 opencount = opencount + 1
283             if message[i] == '}':
284                 closecount = closecount + 1
285             if opencount == closecount:
286                 if header == "":
287                     header = message[:i]
288                     old_end = i + 1
289                 else:
290                     payloads.append(message[old_end:i + 1])
291             i += 1
292
293         return {
294             'header': header,
295             'payloads': payloads
296         }
297
298     def __generate_msl_request_data(self, data):
299         header_encryption_envelope = self.__encrypt(self.__generate_msl_header())
300         header = {
301             'headerdata': base64.standard_b64encode(header_encryption_envelope),
302             'signature': self.__sign(header_encryption_envelope),
303             'mastertoken': self.mastertoken,
304         }
305
306         # Serialize the given Data
307         serialized_data = json.dumps(data)
308         serialized_data = serialized_data.replace('"', '\\"')
309         serialized_data = '[{},{"headers":{},"path":"/cbp/cadmium-11","payload":{"data":"' + serialized_data + '"},"query":""}]\n'
310
311         compressed_data = self.__compress_data(serialized_data)
312
313         # Create FIRST Payload Chunks
314         first_payload = {
315             "messageid": self.current_message_id,
316             "data": compressed_data,
317             "compressionalgo": "GZIP",
318             "sequencenumber": 1,
319             "endofmsg": True
320         }
321         first_payload_encryption_envelope = self.__encrypt(json.dumps(first_payload))
322         first_payload_chunk = {
323             'payload': base64.standard_b64encode(first_payload_encryption_envelope),
324             'signature': self.__sign(first_payload_encryption_envelope),
325         }
326
327         request_data = json.dumps(header) + json.dumps(first_payload_chunk)
328         return request_data
329
330
331
332     def __compress_data(self, data):
333         # GZIP THE DATA
334         out = StringIO()
335         with gzip.GzipFile(fileobj=out, mode="w") as f:
336             f.write(data)
337         return base64.standard_b64encode(out.getvalue())
338
339
340     def __generate_msl_header(self, is_handshake=False, is_key_request=False, compressionalgo="GZIP", encrypt=True):
341         """
342         Function that generates a MSL header dict
343         :return: The base64 encoded JSON String of the header
344         """
345         self.current_message_id = self.rndm.randint(0, pow(2, 52))
346
347         header_data = {
348             'sender': self.esn,
349             'handshake': is_handshake,
350             'nonreplayable': False,
351             'capabilities': {
352                 'languages': ["en-US"],
353                 'compressionalgos': []
354             },
355             'recipient': 'Netflix',
356             'renewable': True,
357             'messageid': self.current_message_id,
358             'timestamp': 1467733923
359         }
360
361         # Add compression algo if not empty
362         if compressionalgo is not "":
363             header_data['capabilities']['compressionalgos'].append(compressionalgo)
364
365         # If this is a keyrequest act diffrent then other requests
366         if is_key_request:
367             public_key = base64.standard_b64encode(self.rsa_key.publickey().exportKey(format='DER'))
368             header_data['keyrequestdata'] = [{
369                 'scheme': 'ASYMMETRIC_WRAPPED',
370                 'keydata': {
371                     'publickey': public_key,
372                     'mechanism': 'JWK_RSA',
373                     'keypairid': 'superKeyPair'
374                 }
375             }]
376         else:
377             if 'usertoken' in self.tokens:
378                 pass
379             else:
380                 # Auth via email and password
381                 header_data['userauthdata'] = {
382                     'scheme': 'EMAIL_PASSWORD',
383                     'authdata': {
384                         'email': self.email,
385                         'password': self.password
386                     }
387                 }
388
389         return json.dumps(header_data)
390
391
392
393     def __encrypt(self, plaintext):
394         """
395         Encrypt the given Plaintext with the encryption key
396         :param plaintext:
397         :return: Serialized JSON String of the encryption Envelope
398         """
399         iv = get_random_bytes(16)
400         encryption_envelope = {
401             'ciphertext': '',
402             'keyid': self.esn + '_' + str(self.sequence_number),
403             'sha256': 'AA==',
404             'iv': base64.standard_b64encode(iv)
405         }
406         # Padd the plaintext
407         plaintext = Padding.pad(plaintext, 16)
408         # Encrypt the text
409         cipher = AES.new(self.encryption_key, AES.MODE_CBC, iv)
410         ciphertext = cipher.encrypt(plaintext)
411         encryption_envelope['ciphertext'] = base64.standard_b64encode(ciphertext)
412         return json.dumps(encryption_envelope)
413
414
415     def __sign(self, text):
416         """
417         Calculates the HMAC signature for the given text with the current sign key and SHA256
418         :param text:
419         :return: Base64 encoded signature
420         """
421         signature = HMAC.new(self.sign_key, text, SHA256).digest()
422         return base64.standard_b64encode(signature)
423
424
425     def __perform_key_handshake(self):
426         header = self.__generate_msl_header(is_key_request=True, is_handshake=True, compressionalgo="", encrypt=False)
427         request = {
428             'entityauthdata': {
429                 'scheme': 'NONE',
430                 'authdata': {
431                     'identity': self.esn
432                 }
433             },
434             'headerdata': base64.standard_b64encode(header),
435             'signature': '',
436         }
437         self.kodi_helper.log(msg='Key Handshake Request:')
438         self.kodi_helper.log(msg=json.dumps(request))
439
440         resp = self.session.post(self.endpoints['manifest'], json.dumps(request, sort_keys=True))
441         if resp.status_code == 200:
442             resp = resp.json()
443             if 'errordata' in resp:
444                 self.kodi_helper.log(msg='Key Exchange failed')
445                 self.kodi_helper.log(msg=base64.standard_b64decode(resp['errordata']))
446                 return False
447             self.__parse_crypto_keys(json.JSONDecoder().decode(base64.standard_b64decode(resp['headerdata'])))
448         else:
449             self.kodi_helper.log(msg='Key Exchange failed')
450             self.kodi_helper.log(msg=resp.text)
451
452     def __parse_crypto_keys(self, headerdata):
453         self.__set_master_token(headerdata['keyresponsedata']['mastertoken'])
454         # Init Decryption
455         encrypted_encryption_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['encryptionkey'])
456         encrypted_sign_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['hmackey'])
457         cipher_rsa = PKCS1_OAEP.new(self.rsa_key)
458
459         # Decrypt encryption key
460         encryption_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_encryption_key))
461         self.encryption_key = base64key_decode(encryption_key_data['k'])
462
463         # Decrypt sign key
464         sign_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_sign_key))
465         self.sign_key = base64key_decode(sign_key_data['k'])
466
467         self.__save_msl_data()
468         self.handshake_performed = True
469
470     def __load_msl_data(self):
471         msl_data = json.JSONDecoder().decode(self.load_file(self.kodi_helper.msl_data_path, 'msl_data.json'))
472         self.__set_master_token(msl_data['tokens']['mastertoken'])
473         self.encryption_key = base64.standard_b64decode(msl_data['encryption_key'])
474         self.sign_key = base64.standard_b64decode(msl_data['sign_key'])
475
476     def __save_msl_data(self):
477         """
478         Saves the keys and tokens in json file
479         :return:
480         """
481         data = {
482             "encryption_key": base64.standard_b64encode(self.encryption_key),
483             'sign_key': base64.standard_b64encode(self.sign_key),
484             'tokens': {
485                 'mastertoken': self.mastertoken
486             }
487         }
488         serialized_data = json.JSONEncoder().encode(data)
489         self.save_file(self.kodi_helper.msl_data_path, 'msl_data.json', serialized_data)
490
491     def __set_master_token(self, master_token):
492         self.mastertoken = master_token
493         self.sequence_number = json.JSONDecoder().decode(base64.standard_b64decode(master_token['tokendata']))['sequencenumber']
494
495     def __load_rsa_keys(self):
496         loaded_key = self.load_file(self.kodi_helper.msl_data_path, 'rsa_key.bin')
497         self.rsa_key = RSA.importKey(loaded_key)
498
499     def __save_rsa_keys(self):
500         self.kodi_helper.log(msg='Save RSA Keys')
501         # Get the DER Base64 of the keys
502         encrypted_key = self.rsa_key.exportKey()
503         self.save_file(self.kodi_helper.msl_data_path, 'rsa_key.bin', encrypted_key)
504
505     @staticmethod
506     def file_exists(msl_data_path, filename):
507         """
508         Checks if a given file exists
509         :param filename: The filename
510         :return: True if so
511         """
512         return os.path.isfile(msl_data_path + filename)
513
514     @staticmethod
515     def save_file(msl_data_path, filename, content):
516         """
517         Saves the given content under given filename
518         :param filename: The filename
519         :param content: The content of the file
520         """
521         with open(msl_data_path + filename, 'w') as file_:
522             file_.write(content)
523             file_.flush()
524
525     @staticmethod
526     def load_file(msl_data_path, filename):
527         """
528         Loads the content of a given filename
529         :param filename: The file to load
530         :return: The content of the file
531         """
532         with open(msl_data_path + filename) as file_:
533             file_content = file_.read()
534         return file_content