802cf662b7b21e77910832a5bc5293e377e8a8f9
[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-h264bpl30-dash",
89                 "playready-h264mpl30-dash",
90                 "playready-h264mpl31-dash",
91                 "playready-h264mpl40-dash",
92                 # "hevc-main-L30-dash-cenc",
93                 # "hevc-main-L31-dash-cenc",
94                 # "hevc-main-L40-dash-cenc",
95                 # "hevc-main-L41-dash-cenc",
96                 # "hevc-main-L50-dash-cenc",
97                 # "hevc-main-L51-dash-cenc",
98                 # "hevc-main10-L30-dash-cenc",
99                 # "hevc-main10-L31-dash-cenc",
100                 # "hevc-main10-L40-dash-cenc",
101                 # "hevc-main10-L41-dash-cenc",
102                 # "hevc-main10-L50-dash-cenc",
103                 # "hevc-main10-L51-dash-cenc",
104                 # "hevc-main10-L30-dash-cenc-prk",
105                 # "hevc-main10-L31-dash-cenc-prk",
106                 # "hevc-main10-L40-dash-cenc-prk",
107                 # "hevc-main10-L41-dash-cenc-prk",
108                 # "hevc-main-L30-L31-dash-cenc-tl",
109                 # "hevc-main-L31-L40-dash-cenc-tl",
110                 # "hevc-main-L40-L41-dash-cenc-tl",
111                 # "hevc-main-L50-L51-dash-cenc-tl",
112                 # "hevc-main10-L30-L31-dash-cenc-tl",
113                 # "hevc-main10-L31-L40-dash-cenc-tl",
114                 # "hevc-main10-L40-L41-dash-cenc-tl",
115                 # "hevc-main10-L50-L51-dash-cenc-tl",
116                 # "hevc-dv-main10-L30-dash-cenc",
117                 # "hevc-dv-main10-L31-dash-cenc",
118                 # "hevc-dv-main10-L40-dash-cenc",
119                 # "hevc-dv-main10-L41-dash-cenc",
120                 # "hevc-dv-main10-L50-dash-cenc",
121                 # "hevc-dv-main10-L51-dash-cenc",
122                 # "hevc-dv5-main10-L30-dash-cenc-prk",
123                 # "hevc-dv5-main10-L31-dash-cenc-prk",
124                 # "hevc-dv5-main10-L40-dash-cenc-prk",
125                 # "hevc-dv5-main10-L41-dash-cenc-prk",
126                 # "hevc-dv5-main10-L50-dash-cenc-prk",
127                 # "hevc-dv5-main10-L51-dash-cenc-prk",
128                 # "hevc-hdr-main10-L30-dash-cenc",
129                 # "hevc-hdr-main10-L31-dash-cenc",
130                 # "hevc-hdr-main10-L40-dash-cenc",
131                 # "hevc-hdr-main10-L41-dash-cenc",
132                 # "hevc-hdr-main10-L50-dash-cenc",
133                 # "hevc-hdr-main10-L51-dash-cenc",
134                 # "hevc-hdr-main10-L30-dash-cenc-prk",
135                 # "hevc-hdr-main10-L31-dash-cenc-prk",
136                 # "hevc-hdr-main10-L40-dash-cenc-prk",
137                 # "hevc-hdr-main10-L41-dash-cenc-prk",
138                 # "hevc-hdr-main10-L50-dash-cenc-prk",
139                 # "hevc-hdr-main10-L51-dash-cenc-prk"
140
141                # 'playready-h264mpl30-dash',
142                 #'playready-h264mpl31-dash',
143                 #'playready-h264mpl40-dash',
144                 #'hevc-main10-L41-dash-cenc',
145                 #'hevc-main10-L50-dash-cenc',
146                 #'hevc-main10-L51-dash-cenc',
147
148
149
150                 # Audio
151                 'heaac-2-dash',
152                 'ddplus-2.0-dash',
153                 'ddplus-5.1-dash',
154                 'dfxp-ls-sdh',
155                 'simplesdh',
156                 'nflx-cmisc',
157                 'BIF240',
158                 'BIF320'
159             ],
160             'drmSystem': 'widevine',
161             'appId': '14673889385265',
162             'sessionParams': {
163                 'pinCapableClient': False,
164                 'uiplaycontext': 'null'
165             },
166             'sessionId': '14673889385265',
167             'trackId': 0,
168             'flavor': 'PRE_FETCH',
169             'secureUrls': False,
170             'supportPreviewContent': True,
171             'forceClearStreams': False,
172             'languages': ['de-DE'],
173             'clientVersion': '4.0004.899.011',
174             'uiVersion': 'akira'
175         }
176         request_data = self.__generate_msl_request_data(manifest_request_data)
177         resp = self.session.post(self.endpoints['manifest'], request_data)
178
179         try:
180             # if the json() does not fail we have an error because the manifest response is a chuncked json response
181             resp.json()
182             self.kodi_helper.log(msg='Error getting Manifest: '+resp.text)
183             return False
184         except ValueError:
185             # json() failed so parse the chunked response
186             self.kodi_helper.log(msg='Got chunked Manifest Response: ' + resp.text)
187             resp = self.__parse_chunked_msl_response(resp.text)
188             self.kodi_helper.log(msg='Parsed chunked Response: ' + json.dumps(resp))
189             data = self.__decrypt_payload_chunk(resp['payloads'][0])
190             return self.__tranform_to_dash(data)
191
192     def get_license(self, challenge, sid):
193         """
194         Requests and returns a license for the given challenge and sid
195         :param challenge: The base64 encoded challenge
196         :param sid: The sid paired to the challengew
197         :return: Base64 representation of the license key or False if no success
198         """
199         license_request_data = {
200             'method': 'license',
201             'licenseType': 'STANDARD',
202             'clientVersion': '4.0004.899.011',
203             'uiVersion': 'akira',
204             'languages': ['de-DE'],
205             'playbackContextId': self.last_playback_context,
206             'drmContextIds': [self.last_drm_context],
207             'challenges': [{
208                 'dataBase64': challenge,
209                 'sessionId': sid
210             }],
211             'clientTime': int(time.time()),
212             'xid': int((int(time.time()) + 0.1612) * 1000)
213
214         }
215         request_data = self.__generate_msl_request_data(license_request_data)
216
217         resp = self.session.post(self.endpoints['license'], request_data)
218
219         try:
220             # If is valid json the request for the licnese failed
221             resp.json()
222             self.kodi_helper.log(msg='Error getting license: '+resp.text)
223             return False
224         except ValueError:
225             # json() failed so we have a chunked json response
226             resp = self.__parse_chunked_msl_response(resp.text)
227             data = self.__decrypt_payload_chunk(resp['payloads'][0])
228             if data['success'] is True:
229                 return data['result']['licenses'][0]['data']
230             else:
231                 self.kodi_helper.log(msg='Error getting license: ' + json.dumps(data))
232                 return False
233
234     def __decrypt_payload_chunk(self, payloadchunk):
235         payloadchunk = json.JSONDecoder().decode(payloadchunk)
236         encryption_envelope = json.JSONDecoder().decode(base64.standard_b64decode(payloadchunk['payload']))
237         # Decrypt the text
238         cipher = AES.new(self.encryption_key, AES.MODE_CBC, base64.standard_b64decode(encryption_envelope['iv']))
239         plaintext = cipher.decrypt(base64.standard_b64decode(encryption_envelope['ciphertext']))
240         # unpad the plaintext
241         plaintext = json.JSONDecoder().decode(Padding.unpad(plaintext, 16))
242         data = plaintext['data']
243
244         # uncompress data if compressed
245         if plaintext['compressionalgo'] == 'GZIP':
246             data = zlib.decompress(base64.standard_b64decode(data), 16 + zlib.MAX_WBITS)
247         else:
248             data = base64.standard_b64decode(data)
249
250         data = json.JSONDecoder().decode(data)[1]['payload']['data']
251         data = base64.standard_b64decode(data)
252         return json.JSONDecoder().decode(data)
253
254
255     def __tranform_to_dash(self, manifest):
256
257         self.save_file(self.kodi_helper.msl_data_path, 'manifest.json', json.dumps(manifest))
258         manifest = manifest['result']['viewables'][0]
259
260         self.last_playback_context = manifest['playbackContextId']
261         self.last_drm_context = manifest['drmContextId']
262
263         #Check for pssh
264         pssh = ''
265         if 'psshb64' in manifest:
266             if len(manifest['psshb64']) >= 1:
267                 pssh = manifest['psshb64'][0]
268
269         seconds = manifest['runtime']/1000
270         duration = "PT"+str(seconds)+".00S"
271
272         root = ET.Element('MPD')
273         root.attrib['xmlns'] = 'urn:mpeg:dash:schema:mpd:2011'
274         root.attrib['xmlns:cenc'] = 'urn:mpeg:cenc:2013'
275         root.attrib['mediaPresentationDuration'] = duration
276
277         period = ET.SubElement(root, 'Period', start='PT0S', duration=duration)
278
279         # One Adaption Set for Video
280         for video_track in manifest['videoTracks']:
281             video_adaption_set = ET.SubElement(period, 'AdaptationSet', mimeType='video/mp4', contentType="video")
282             # Content Protection
283             protection = ET.SubElement(video_adaption_set, 'ContentProtection',
284                           schemeIdUri='urn:uuid:EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED')
285             if pssh is not '':
286                 ET.SubElement(protection, 'cenc:pssh').text = pssh
287
288             for downloadable in video_track['downloadables']:
289
290                 hdcp_versions = '0.0'
291                 for hdcp in downloadable['hdcpVersions']:
292                     if hdcp != 'none':
293                         hdcp_versions = hdcp
294
295                 rep = ET.SubElement(video_adaption_set, 'Representation',
296                                     width=str(downloadable['width']),
297                                     height=str(downloadable['height']),
298                                     bandwidth=str(downloadable['bitrate']*1024),
299                                     hdcp=hdcp_versions,
300                                     nflxContentProfile=str(downloadable['contentProfile']),
301                                     codecs='h264',
302                                     mimeType='video/mp4')
303
304                 #BaseURL
305                 ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
306                 # Init an Segment block
307                 segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true")
308                 ET.SubElement(segment_base, 'Initialization', range='0-60000')
309
310
311
312         # Multiple Adaption Set for audio
313         for audio_track in manifest['audioTracks']:
314             audio_adaption_set = ET.SubElement(period, 'AdaptationSet',
315                                                lang=audio_track['bcp47'],
316                                                contentType='audio',
317                                                mimeType='audio/mp4')
318             for downloadable in audio_track['downloadables']:
319                 codec = 'aac'
320                 print downloadable
321                 if downloadable['contentProfile'] == 'ddplus-2.0-dash' or downloadable['contentProfile'] == 'ddplus-5.1-dash':
322                     codec = 'ec-3'
323                 print "codec is: " + codec
324                 rep = ET.SubElement(audio_adaption_set, 'Representation',
325                                     codecs=codec,
326                                     bandwidth=str(downloadable['bitrate']*1024),
327                                     mimeType='audio/mp4')
328
329                 #AudioChannel Config
330                 ET.SubElement(rep, 'AudioChannelConfiguration',
331                               schemeIdUri='urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
332                               value=str(audio_track['channelsCount']))
333
334                 #BaseURL
335                 ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
336                 # Index range
337                 segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true")
338                 ET.SubElement(segment_base, 'Initialization', range='0-60000')
339
340
341         xml = ET.tostring(root, encoding='utf-8', method='xml')
342         xml = xml.replace('\n', '').replace('\r', '')
343         return xml
344
345     def __get_base_url(self, urls):
346         for key in urls:
347             return urls[key]
348
349     def __parse_chunked_msl_response(self, message):
350         i = 0
351         opencount = 0
352         closecount = 0
353         header = ""
354         payloads = []
355         old_end = 0
356
357         while i < len(message):
358             if message[i] == '{':
359                 opencount = opencount + 1
360             if message[i] == '}':
361                 closecount = closecount + 1
362             if opencount == closecount:
363                 if header == "":
364                     header = message[:i]
365                     old_end = i + 1
366                 else:
367                     payloads.append(message[old_end:i + 1])
368             i += 1
369
370         return {
371             'header': header,
372             'payloads': payloads
373         }
374
375     def __generate_msl_request_data(self, data):
376         header_encryption_envelope = self.__encrypt(self.__generate_msl_header())
377         header = {
378             'headerdata': base64.standard_b64encode(header_encryption_envelope),
379             'signature': self.__sign(header_encryption_envelope),
380             'mastertoken': self.mastertoken,
381         }
382
383         # Serialize the given Data
384         serialized_data = json.dumps(data)
385         serialized_data = serialized_data.replace('"', '\\"')
386         serialized_data = '[{},{"headers":{},"path":"/cbp/cadmium-13","payload":{"data":"' + serialized_data + '"},"query":""}]\n'
387
388         compressed_data = self.__compress_data(serialized_data)
389
390         # Create FIRST Payload Chunks
391         first_payload = {
392             "messageid": self.current_message_id,
393             "data": compressed_data,
394             "compressionalgo": "GZIP",
395             "sequencenumber": 1,
396             "endofmsg": True
397         }
398         first_payload_encryption_envelope = self.__encrypt(json.dumps(first_payload))
399         first_payload_chunk = {
400             'payload': base64.standard_b64encode(first_payload_encryption_envelope),
401             'signature': self.__sign(first_payload_encryption_envelope),
402         }
403
404         request_data = json.dumps(header) + json.dumps(first_payload_chunk)
405         return request_data
406
407
408
409     def __compress_data(self, data):
410         # GZIP THE DATA
411         out = StringIO()
412         with gzip.GzipFile(fileobj=out, mode="w") as f:
413             f.write(data)
414         return base64.standard_b64encode(out.getvalue())
415
416
417     def __generate_msl_header(self, is_handshake=False, is_key_request=False, compressionalgo="GZIP", encrypt=True):
418         """
419         Function that generates a MSL header dict
420         :return: The base64 encoded JSON String of the header
421         """
422         self.current_message_id = self.rndm.randint(0, pow(2, 52))
423
424         header_data = {
425             'sender': self.esn,
426             'handshake': is_handshake,
427             'nonreplayable': False,
428             'capabilities': {
429                 'languages': ["en-US"],
430                 'compressionalgos': []
431             },
432             'recipient': 'Netflix',
433             'renewable': True,
434             'messageid': self.current_message_id,
435             'timestamp': 1467733923
436         }
437
438         # Add compression algo if not empty
439         if compressionalgo is not "":
440             header_data['capabilities']['compressionalgos'].append(compressionalgo)
441
442         # If this is a keyrequest act diffrent then other requests
443         if is_key_request:
444             public_key = base64.standard_b64encode(self.rsa_key.publickey().exportKey(format='DER'))
445             header_data['keyrequestdata'] = [{
446                 'scheme': 'ASYMMETRIC_WRAPPED',
447                 'keydata': {
448                     'publickey': public_key,
449                     'mechanism': 'JWK_RSA',
450                     'keypairid': 'superKeyPair'
451                 }
452             }]
453         else:
454             if 'usertoken' in self.tokens:
455                 pass
456             else:
457                 # Auth via email and password
458                 header_data['userauthdata'] = {
459                     'scheme': 'EMAIL_PASSWORD',
460                     'authdata': {
461                         'email': self.email,
462                         'password': self.password
463                     }
464                 }
465
466         return json.dumps(header_data)
467
468
469
470     def __encrypt(self, plaintext):
471         """
472         Encrypt the given Plaintext with the encryption key
473         :param plaintext:
474         :return: Serialized JSON String of the encryption Envelope
475         """
476         iv = get_random_bytes(16)
477         encryption_envelope = {
478             'ciphertext': '',
479             'keyid': self.esn + '_' + str(self.sequence_number),
480             'sha256': 'AA==',
481             'iv': base64.standard_b64encode(iv)
482         }
483         # Padd the plaintext
484         plaintext = Padding.pad(plaintext, 16)
485         # Encrypt the text
486         cipher = AES.new(self.encryption_key, AES.MODE_CBC, iv)
487         ciphertext = cipher.encrypt(plaintext)
488         encryption_envelope['ciphertext'] = base64.standard_b64encode(ciphertext)
489         return json.dumps(encryption_envelope)
490
491
492     def __sign(self, text):
493         """
494         Calculates the HMAC signature for the given text with the current sign key and SHA256
495         :param text:
496         :return: Base64 encoded signature
497         """
498         signature = HMAC.new(self.sign_key, text, SHA256).digest()
499         return base64.standard_b64encode(signature)
500
501
502     def __perform_key_handshake(self):
503         header = self.__generate_msl_header(is_key_request=True, is_handshake=True, compressionalgo="", encrypt=False)
504         request = {
505             'entityauthdata': {
506                 'scheme': 'NONE',
507                 'authdata': {
508                     'identity': self.esn
509                 }
510             },
511             'headerdata': base64.standard_b64encode(header),
512             'signature': '',
513         }
514         self.kodi_helper.log(msg='Key Handshake Request:')
515         self.kodi_helper.log(msg=json.dumps(request))
516
517         resp = self.session.post(self.endpoints['manifest'], json.dumps(request, sort_keys=True))
518         if resp.status_code == 200:
519             resp = resp.json()
520             if 'errordata' in resp:
521                 self.kodi_helper.log(msg='Key Exchange failed')
522                 self.kodi_helper.log(msg=base64.standard_b64decode(resp['errordata']))
523                 return False
524             self.__parse_crypto_keys(json.JSONDecoder().decode(base64.standard_b64decode(resp['headerdata'])))
525         else:
526             self.kodi_helper.log(msg='Key Exchange failed')
527             self.kodi_helper.log(msg=resp.text)
528
529     def __parse_crypto_keys(self, headerdata):
530         self.__set_master_token(headerdata['keyresponsedata']['mastertoken'])
531         # Init Decryption
532         encrypted_encryption_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['encryptionkey'])
533         encrypted_sign_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['hmackey'])
534         cipher_rsa = PKCS1_OAEP.new(self.rsa_key)
535
536         # Decrypt encryption key
537         encryption_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_encryption_key))
538         self.encryption_key = base64key_decode(encryption_key_data['k'])
539
540         # Decrypt sign key
541         sign_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_sign_key))
542         self.sign_key = base64key_decode(sign_key_data['k'])
543
544         self.__save_msl_data()
545         self.handshake_performed = True
546
547     def __load_msl_data(self):
548         msl_data = json.JSONDecoder().decode(self.load_file(self.kodi_helper.msl_data_path, 'msl_data.json'))
549         self.__set_master_token(msl_data['tokens']['mastertoken'])
550         self.encryption_key = base64.standard_b64decode(msl_data['encryption_key'])
551         self.sign_key = base64.standard_b64decode(msl_data['sign_key'])
552
553     def __save_msl_data(self):
554         """
555         Saves the keys and tokens in json file
556         :return:
557         """
558         data = {
559             "encryption_key": base64.standard_b64encode(self.encryption_key),
560             'sign_key': base64.standard_b64encode(self.sign_key),
561             'tokens': {
562                 'mastertoken': self.mastertoken
563             }
564         }
565         serialized_data = json.JSONEncoder().encode(data)
566         self.save_file(self.kodi_helper.msl_data_path, 'msl_data.json', serialized_data)
567
568     def __set_master_token(self, master_token):
569         self.mastertoken = master_token
570         self.sequence_number = json.JSONDecoder().decode(base64.standard_b64decode(master_token['tokendata']))['sequencenumber']
571
572     def __load_rsa_keys(self):
573         loaded_key = self.load_file(self.kodi_helper.msl_data_path, 'rsa_key.bin')
574         self.rsa_key = RSA.importKey(loaded_key)
575
576     def __save_rsa_keys(self):
577         self.kodi_helper.log(msg='Save RSA Keys')
578         # Get the DER Base64 of the keys
579         encrypted_key = self.rsa_key.exportKey()
580         self.save_file(self.kodi_helper.msl_data_path, 'rsa_key.bin', encrypted_key)
581
582     @staticmethod
583     def file_exists(msl_data_path, filename):
584         """
585         Checks if a given file exists
586         :param filename: The filename
587         :return: True if so
588         """
589         return os.path.isfile(msl_data_path + filename)
590
591     @staticmethod
592     def save_file(msl_data_path, filename, content):
593         """
594         Saves the given content under given filename
595         :param filename: The filename
596         :param content: The content of the file
597         """
598         with open(msl_data_path + filename, 'w') as file_:
599             file_.write(content)
600             file_.flush()
601
602     @staticmethod
603     def load_file(msl_data_path, filename):
604         """
605         Loads the content of a given filename
606         :param filename: The file to load
607         :return: The content of the file
608         """
609         with open(msl_data_path + filename) as file_:
610             file_content = file_.read()
611         return file_content