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