Adds profile to manifest request
[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 Cryptodome.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     #esn = "NFCDIE-02-DCH84Q2EK3N6VFVQJ0NLRQ27498N0F"
40     current_message_id = 0
41     session = requests.session()
42     rndm = random.SystemRandom()
43     tokens = []
44     endpoints = {
45         'manifest': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/manifest',
46         'license': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/license'
47     }
48
49     def __init__(self, email, password, kodi_helper):
50         """
51         The Constructor checks for already existing crypto Keys.
52         If they exist it will load the existing keys
53         """
54         self.email = email
55         self.password = password
56         self.kodi_helper = kodi_helper
57         try:
58             os.mkdir(self.kodi_helper.msl_data_path)
59         except OSError:
60             pass
61
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()
70         else:
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()
76
77     def load_manifest(self, viewable_id):
78         """
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
82         """
83         manifest_request_data = {
84             'method': 'manifest',
85             'lookupType': 'PREPARE',
86             'viewableIds': [viewable_id],
87             'profiles': [
88                 'playready-h264mpl30-dash',
89                 'playready-h264mpl31-dash',
90                 'playready-h264mpl40-dash',
91                 'heaac-2-dash',
92                 'dfxp-ls-sdh',
93                 'simplesdh',
94                 'nflx-cmisc',
95                 'BIF240',
96                 'BIF320'
97             ],
98             'drmSystem': 'widevine',
99             'appId': '14673889385265',
100             'sessionParams': {
101                 'pinCapableClient': False,
102                 'uiplaycontext': 'null'
103             },
104             'sessionId': '14673889385265',
105             'trackId': 0,
106             'flavor': 'PRE_FETCH',
107             'secureUrls': False,
108             'supportPreviewContent': True,
109             'forceClearStreams': False,
110             'languages': ['de-DE'],
111             'clientVersion': '4.0004.899.011',
112             'uiVersion': 'akira'
113         }
114         request_data = self.__generate_msl_request_data(manifest_request_data)
115         resp = self.session.post(self.endpoints['manifest'], request_data)
116
117         try:
118             # if the json() does not fail we have an error because the manifest response is a chuncked json response
119             resp.json()
120             self.kodi_helper.log(msg='Error getting Manifest: '+resp.text)
121             return False
122         except ValueError:
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)
129
130     def get_license(self, challenge, sid):
131         """
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
136         """
137         license_request_data = {
138             'method': 'license',
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],
145             'challenges': [{
146                 'dataBase64': challenge,
147                 'sessionId': sid
148             }],
149             'clientTime': int(time.time()),
150             'xid': int((int(time.time()) + 0.1612) * 1000)
151
152         }
153         request_data = self.__generate_msl_request_data(license_request_data)
154
155         resp = self.session.post(self.endpoints['license'], request_data)
156
157         try:
158             # If is valid json the request for the licnese failed
159             resp.json()
160             self.kodi_helper.log(msg='Error getting license: '+resp.text)
161             return False
162         except ValueError:
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']
168             else:
169                 self.kodi_helper.log(msg='Error getting license: ' + json.dumps(data))
170                 return False
171
172     def __decrypt_payload_chunk(self, payloadchunk):
173         payloadchunk = json.JSONDecoder().decode(payloadchunk)
174         encryption_envelope = json.JSONDecoder().decode(base64.standard_b64decode(payloadchunk['payload']))
175         # Decrypt the text
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']
181
182         # uncompress data if compressed
183         if plaintext['compressionalgo'] == 'GZIP':
184             data = zlib.decompress(base64.standard_b64decode(data), 16 + zlib.MAX_WBITS)
185         else:
186             data = base64.standard_b64decode(data)
187
188         data = json.JSONDecoder().decode(data)[1]['payload']['data']
189         data = base64.standard_b64decode(data)
190         return json.JSONDecoder().decode(data)
191
192
193     def __tranform_to_dash(self, manifest):
194
195         self.save_file(self.kodi_helper.msl_data_path, 'manifest.json', json.dumps(manifest))
196         manifest = manifest['result']['viewables'][0]
197
198         self.last_playback_context = manifest['playbackContextId']
199         self.last_drm_context = manifest['drmContextId']
200
201         #Check for pssh
202         pssh = ''
203         if 'psshb64' in manifest:
204             if len(manifest['psshb64']) >= 1:
205                 pssh = manifest['psshb64'][0]
206
207         seconds = manifest['runtime']/1000
208         duration = "PT"+str(seconds)+".00S"
209
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
214
215         period = ET.SubElement(root, 'Period', start='PT0S', duration=duration)
216
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")
220             # Content Protection
221             protection = ET.SubElement(video_adaption_set, 'ContentProtection',
222                           schemeIdUri='urn:uuid:EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED')
223             if pssh is not '':
224                 ET.SubElement(protection, 'cenc:pssh').text = pssh
225
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),
231                                     codecs='h264',
232                                     mimeType='video/mp4')
233
234                 #BaseURL
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')
239
240
241
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'],
246                                                contentType='audio',
247                                                mimeType='audio/mp4')
248             for downloadable in audio_track['downloadables']:
249                 rep = ET.SubElement(audio_adaption_set, 'Representation',
250                                     codecs='aac',
251                                     bandwidth=str(downloadable['bitrate']*1024),
252                                     mimeType='audio/mp4')
253
254                 #AudioChannel Config
255                 ET.SubElement(rep, 'AudioChannelConfiguration',
256                               schemeIdUri='urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
257                               value=str(audio_track['channelsCount']))
258
259                 #BaseURL
260                 ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
261                 # Index range
262                 segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true")
263                 ET.SubElement(segment_base, 'Initialization', range='0-60000')
264
265
266         xml = ET.tostring(root, encoding='utf-8', method='xml')
267         xml = xml.replace('\n', '').replace('\r', '')
268         return xml
269
270     def __get_base_url(self, urls):
271         for key in urls:
272             return urls[key]
273
274     def __parse_chunked_msl_response(self, message):
275         i = 0
276         opencount = 0
277         closecount = 0
278         header = ""
279         payloads = []
280         old_end = 0
281
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:
288                 if header == "":
289                     header = message[:i]
290                     old_end = i + 1
291                 else:
292                     payloads.append(message[old_end:i + 1])
293             i += 1
294
295         return {
296             'header': header,
297             'payloads': payloads
298         }
299
300     def __generate_msl_request_data(self, data):
301         header_encryption_envelope = self.__encrypt(self.__generate_msl_header())
302         header = {
303             'headerdata': base64.standard_b64encode(header_encryption_envelope),
304             'signature': self.__sign(header_encryption_envelope),
305             'mastertoken': self.mastertoken,
306         }
307
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'
312
313         compressed_data = self.__compress_data(serialized_data)
314
315         # Create FIRST Payload Chunks
316         first_payload = {
317             "messageid": self.current_message_id,
318             "data": compressed_data,
319             "compressionalgo": "GZIP",
320             "sequencenumber": 1,
321             "endofmsg": True
322         }
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),
327         }
328
329         request_data = json.dumps(header) + json.dumps(first_payload_chunk)
330         return request_data
331
332
333
334     def __compress_data(self, data):
335         # GZIP THE DATA
336         out = StringIO()
337         with gzip.GzipFile(fileobj=out, mode="w") as f:
338             f.write(data)
339         return base64.standard_b64encode(out.getvalue())
340
341
342     def __generate_msl_header(self, is_handshake=False, is_key_request=False, compressionalgo="GZIP", encrypt=True):
343         """
344         Function that generates a MSL header dict
345         :return: The base64 encoded JSON String of the header
346         """
347         self.current_message_id = self.rndm.randint(0, pow(2, 52))
348
349         header_data = {
350             'sender': self.esn,
351             'handshake': is_handshake,
352             'nonreplayable': False,
353             'capabilities': {
354                 'languages': ["en-US"],
355                 'compressionalgos': []
356             },
357             'recipient': 'Netflix',
358             'renewable': True,
359             'messageid': self.current_message_id,
360             'timestamp': 1467733923
361         }
362
363         # Add compression algo if not empty
364         if compressionalgo is not "":
365             header_data['capabilities']['compressionalgos'].append(compressionalgo)
366
367         # If this is a keyrequest act diffrent then other requests
368         if is_key_request:
369             public_key = base64.standard_b64encode(self.rsa_key.publickey().exportKey(format='DER'))
370             header_data['keyrequestdata'] = [{
371                 'scheme': 'ASYMMETRIC_WRAPPED',
372                 'keydata': {
373                     'publickey': public_key,
374                     'mechanism': 'JWK_RSA',
375                     'keypairid': 'superKeyPair'
376                 }
377             }]
378         else:
379             if 'usertoken' in self.tokens:
380                 pass
381             else:
382                 # Auth via email and password
383                 header_data['userauthdata'] = {
384                     'scheme': 'EMAIL_PASSWORD',
385                     'authdata': {
386                         'email': self.email,
387                         'password': self.password
388                     }
389                 }
390
391         return json.dumps(header_data)
392
393
394
395     def __encrypt(self, plaintext):
396         """
397         Encrypt the given Plaintext with the encryption key
398         :param plaintext:
399         :return: Serialized JSON String of the encryption Envelope
400         """
401         iv = get_random_bytes(16)
402         encryption_envelope = {
403             'ciphertext': '',
404             'keyid': self.esn + '_' + str(self.sequence_number),
405             'sha256': 'AA==',
406             'iv': base64.standard_b64encode(iv)
407         }
408         # Padd the plaintext
409         plaintext = Padding.pad(plaintext, 16)
410         # Encrypt the text
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)
415
416
417     def __sign(self, text):
418         """
419         Calculates the HMAC signature for the given text with the current sign key and SHA256
420         :param text:
421         :return: Base64 encoded signature
422         """
423         signature = HMAC.new(self.sign_key, text, SHA256).digest()
424         return base64.standard_b64encode(signature)
425
426
427     def __perform_key_handshake(self):
428         header = self.__generate_msl_header(is_key_request=True, is_handshake=True, compressionalgo="", encrypt=False)
429         request = {
430             'entityauthdata': {
431                 'scheme': 'NONE',
432                 'authdata': {
433                     'identity': self.esn
434                 }
435             },
436             'headerdata': base64.standard_b64encode(header),
437             'signature': '',
438         }
439         self.kodi_helper.log(msg='Key Handshake Request:')
440         self.kodi_helper.log(msg=json.dumps(request))
441
442         resp = self.session.post(self.endpoints['manifest'], json.dumps(request, sort_keys=True))
443         if resp.status_code == 200:
444             resp = resp.json()
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']))
448                 return False
449             self.__parse_crypto_keys(json.JSONDecoder().decode(base64.standard_b64decode(resp['headerdata'])))
450         else:
451             self.kodi_helper.log(msg='Key Exchange failed')
452             self.kodi_helper.log(msg=resp.text)
453
454     def __parse_crypto_keys(self, headerdata):
455         self.__set_master_token(headerdata['keyresponsedata']['mastertoken'])
456         # Init Decryption
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)
460
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'])
464
465         # Decrypt sign key
466         sign_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_sign_key))
467         self.sign_key = base64key_decode(sign_key_data['k'])
468
469         self.__save_msl_data()
470         self.handshake_performed = True
471
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'])
477
478     def __save_msl_data(self):
479         """
480         Saves the keys and tokens in json file
481         :return:
482         """
483         data = {
484             "encryption_key": base64.standard_b64encode(self.encryption_key),
485             'sign_key': base64.standard_b64encode(self.sign_key),
486             'tokens': {
487                 'mastertoken': self.mastertoken
488             }
489         }
490         serialized_data = json.JSONEncoder().encode(data)
491         self.save_file(self.kodi_helper.msl_data_path, 'msl_data.json', serialized_data)
492
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']
496
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)
500
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)
506
507     @staticmethod
508     def file_exists(msl_data_path, filename):
509         """
510         Checks if a given file exists
511         :param filename: The filename
512         :return: True if so
513         """
514         return os.path.isfile(msl_data_path + filename)
515
516     @staticmethod
517     def save_file(msl_data_path, filename, content):
518         """
519         Saves the given content under given filename
520         :param filename: The filename
521         :param content: The content of the file
522         """
523         with open(msl_data_path + filename, 'w') as file_:
524             file_.write(content)
525             file_.flush()
526
527     @staticmethod
528     def load_file(msl_data_path, filename):
529         """
530         Loads the content of a given filename
531         :param filename: The file to load
532         :return: The content of the file
533         """
534         with open(msl_data_path + filename) as file_:
535             file_content = file_.read()
536         return file_content