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