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