7 from StringIO import StringIO
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 Crypto.Hash import HMAC, SHA256
17 from Cryptodome.Util import Padding
18 import xml.etree.ElementTree as ET
20 pp = pprint.PrettyPrinter(indent=4)
22 def base64key_decode(payload):
29 raise ValueError('Invalid base64 string')
30 return base64.urlsafe_b64decode(payload.encode('utf-8'))
34 handshake_performed = False # Is a handshake already performed and the keys loaded
36 last_playback_context = ''
37 #esn = "NFCDCH-LX-CQE0NU6PA5714R25VPLXVU2A193T36"
38 esn = "WWW-BROWSE-D7GW1G4NPXGR1F0X1H3EQGY3V1F5WE"
39 current_message_id = 0
40 session = requests.session()
41 rndm = random.SystemRandom()
44 'manifest': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/manifest',
45 'license': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/license'
48 def __init__(self, email, password, kodi_helper):
50 The Constructor checks for already existing crypto Keys.
51 If they exist it will load the existing keys
54 self.password = password
55 self.kodi_helper = kodi_helper
57 os.mkdir(self.kodi_helper.msl_data_path)
61 if self.file_exists(self.kodi_helper.msl_data_path, 'msl_data.json'):
62 self.kodi_helper.log(msg='MSL Data exists. Use old Tokens.')
63 self.__load_msl_data()
64 self.handshake_performed = True
65 elif self.file_exists(self.kodi_helper.msl_data_path, 'rsa_key.bin'):
66 self.kodi_helper.log(msg='RSA Keys do already exist load old ones')
67 self.__load_rsa_keys()
68 self.__perform_key_handshake()
70 self.kodi_helper.log(msg='Create new RSA Keys')
71 # Create new Key Pair and save
72 self.rsa_key = RSA.generate(2048)
73 self.__save_rsa_keys()
74 self.__perform_key_handshake()
76 def load_manifest(self, viewable_id):
78 Loads the manifets for the given viewable_id and returns a mpd-XML-Manifest
79 :param viewable_id: The id of of the viewable
80 :return: MPD XML Manifest or False if no success
82 manifest_request_data = {
84 'lookupType': 'PREPARE',
85 'viewableIds': [viewable_id],
87 'playready-h264mpl30-dash',
88 'playready-h264mpl31-dash',
96 'drmSystem': 'widevine',
97 'appId': '14673889385265',
99 'pinCapableClient': False,
100 'uiplaycontext': 'null'
102 'sessionId': '14673889385265',
104 'flavor': 'PRE_FETCH',
106 'supportPreviewContent': True,
107 'forceClearStreams': False,
108 'languages': ['de-DE'],
109 'clientVersion': '4.0004.899.011',
112 request_data = self.__generate_msl_request_data(manifest_request_data)
113 resp = self.session.post(self.endpoints['manifest'], request_data)
116 # if the json() does not fail we have an error because the manifest response is a chuncked json response
118 self.kodi_helper.log(msg='Error getting Manifest: '+resp.text)
121 # json() failed so parse the chunked response
122 self.kodi_helper.log(msg='Got chunked Manifest Response: ' + resp.text)
123 resp = self.__parse_chunked_msl_response(resp.text)
124 self.kodi_helper.log(msg='Parsed chunked Response: ' + json.dumps(resp))
125 data = self.__decrypt_payload_chunk(resp['payloads'][0])
126 return self.__tranform_to_dash(data)
128 def get_license(self, challenge, sid):
130 Requests and returns a license for the given challenge and sid
131 :param challenge: The base64 encoded challenge
132 :param sid: The sid paired to the challengew
133 :return: Base64 representation of the license key or False if no success
135 license_request_data = {
137 'licenseType': 'STANDARD',
138 'clientVersion': '4.0004.899.011',
139 'uiVersion': 'akira',
140 'languages': ['de-DE'],
141 'playbackContextId': self.last_playback_context,
142 'drmContextIds': [self.last_drm_context],
144 'dataBase64': challenge,
147 'clientTime': int(time.time()),
148 'xid': int((int(time.time()) + 0.1612) * 1000)
151 request_data = self.__generate_msl_request_data(license_request_data)
153 resp = self.session.post(self.endpoints['license'], request_data)
156 # If is valid json the request for the licnese failed
158 self.kodi_helper.log(msg='Error getting license: '+resp.text)
161 # json() failed so we have a chunked json response
162 resp = self.__parse_chunked_msl_response(resp.text)
163 data = self.__decrypt_payload_chunk(resp['payloads'][0])
164 if data['success'] is True:
165 return data['result']['licenses'][0]['data']
167 self.kodi_helper.log(msg='Error getting license: ' + json.dumps(data))
170 def __decrypt_payload_chunk(self, payloadchunk):
171 payloadchunk = json.JSONDecoder().decode(payloadchunk)
172 encryption_envelope = json.JSONDecoder().decode(base64.standard_b64decode(payloadchunk['payload']))
174 cipher = AES.new(self.encryption_key, AES.MODE_CBC, base64.standard_b64decode(encryption_envelope['iv']))
175 plaintext = cipher.decrypt(base64.standard_b64decode(encryption_envelope['ciphertext']))
176 # unpad the plaintext
177 plaintext = json.JSONDecoder().decode(Padding.unpad(plaintext, 16))
178 data = plaintext['data']
180 # uncompress data if compressed
181 if plaintext['compressionalgo'] == 'GZIP':
182 data = zlib.decompress(base64.standard_b64decode(data), 16 + zlib.MAX_WBITS)
184 data = base64.standard_b64decode(data)
186 data = json.JSONDecoder().decode(data)[1]['payload']['data']
187 data = base64.standard_b64decode(data)
188 return json.JSONDecoder().decode(data)
191 def __tranform_to_dash(self, manifest):
193 self.save_file(self.kodi_helper.msl_data_path, 'manifest.json', json.dumps(manifest))
194 manifest = manifest['result']['viewables'][0]
196 self.last_playback_context = manifest['playbackContextId']
197 self.last_drm_context = manifest['drmContextId']
201 if 'psshb64' in manifest:
202 if len(manifest['psshb64']) >= 1:
203 pssh = manifest['psshb64'][0]
205 seconds = manifest['runtime']/1000
206 duration = "PT"+str(seconds)+".00S"
208 root = ET.Element('MPD')
209 root.attrib['xmlns'] = 'urn:mpeg:dash:schema:mpd:2011'
210 root.attrib['xmlns:cenc'] = 'urn:mpeg:cenc:2013'
211 root.attrib['mediaPresentationDuration'] = duration
213 period = ET.SubElement(root, 'Period', start='PT0S', duration=duration)
215 # One Adaption Set for Video
216 for video_track in manifest['videoTracks']:
217 video_adaption_set = ET.SubElement(period, 'AdaptationSet', mimeType='video/mp4', contentType="video")
219 protection = ET.SubElement(video_adaption_set, 'ContentProtection',
220 schemeIdUri='urn:uuid:EDEF8BA9-79D6-4ACE-A3C8-27DCD51D21ED')
222 ET.SubElement(protection, 'cenc:pssh').text = pssh
224 for downloadable in video_track['downloadables']:
225 rep = ET.SubElement(video_adaption_set, 'Representation',
226 width=str(downloadable['width']),
227 height=str(downloadable['height']),
228 bandwidth=str(downloadable['bitrate']*1024),
230 mimeType='video/mp4')
233 ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
234 # Init an Segment block
235 segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true")
236 ET.SubElement(segment_base, 'Initialization', range='0-60000')
240 # Multiple Adaption Set for audio
241 for audio_track in manifest['audioTracks']:
242 audio_adaption_set = ET.SubElement(period, 'AdaptationSet',
243 lang=audio_track['bcp47'],
245 mimeType='audio/mp4')
246 for downloadable in audio_track['downloadables']:
247 rep = ET.SubElement(audio_adaption_set, 'Representation',
249 bandwidth=str(downloadable['bitrate']*1024),
250 mimeType='audio/mp4')
253 ET.SubElement(rep, 'AudioChannelConfiguration',
254 schemeIdUri='urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
255 value=str(audio_track['channelsCount']))
258 ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
260 segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true")
261 ET.SubElement(segment_base, 'Initialization', range='0-60000')
264 xml = ET.tostring(root, encoding='utf-8', method='xml')
265 xml = xml.replace('\n', '').replace('\r', '')
268 def __get_base_url(self, urls):
272 def __parse_chunked_msl_response(self, message):
280 while i < len(message):
281 if message[i] == '{':
282 opencount = opencount + 1
283 if message[i] == '}':
284 closecount = closecount + 1
285 if opencount == closecount:
290 payloads.append(message[old_end:i + 1])
298 def __generate_msl_request_data(self, data):
299 header_encryption_envelope = self.__encrypt(self.__generate_msl_header())
301 'headerdata': base64.standard_b64encode(header_encryption_envelope),
302 'signature': self.__sign(header_encryption_envelope),
303 'mastertoken': self.mastertoken,
306 # Serialize the given Data
307 serialized_data = json.dumps(data)
308 serialized_data = serialized_data.replace('"', '\\"')
309 serialized_data = '[{},{"headers":{},"path":"/cbp/cadmium-11","payload":{"data":"' + serialized_data + '"},"query":""}]\n'
311 compressed_data = self.__compress_data(serialized_data)
313 # Create FIRST Payload Chunks
315 "messageid": self.current_message_id,
316 "data": compressed_data,
317 "compressionalgo": "GZIP",
321 first_payload_encryption_envelope = self.__encrypt(json.dumps(first_payload))
322 first_payload_chunk = {
323 'payload': base64.standard_b64encode(first_payload_encryption_envelope),
324 'signature': self.__sign(first_payload_encryption_envelope),
327 request_data = json.dumps(header) + json.dumps(first_payload_chunk)
332 def __compress_data(self, data):
335 with gzip.GzipFile(fileobj=out, mode="w") as f:
337 return base64.standard_b64encode(out.getvalue())
340 def __generate_msl_header(self, is_handshake=False, is_key_request=False, compressionalgo="GZIP", encrypt=True):
342 Function that generates a MSL header dict
343 :return: The base64 encoded JSON String of the header
345 self.current_message_id = self.rndm.randint(0, pow(2, 52))
349 'handshake': is_handshake,
350 'nonreplayable': False,
352 'languages': ["en-US"],
353 'compressionalgos': []
355 'recipient': 'Netflix',
357 'messageid': self.current_message_id,
358 'timestamp': 1467733923
361 # Add compression algo if not empty
362 if compressionalgo is not "":
363 header_data['capabilities']['compressionalgos'].append(compressionalgo)
365 # If this is a keyrequest act diffrent then other requests
367 public_key = base64.standard_b64encode(self.rsa_key.publickey().exportKey(format='DER'))
368 header_data['keyrequestdata'] = [{
369 'scheme': 'ASYMMETRIC_WRAPPED',
371 'publickey': public_key,
372 'mechanism': 'JWK_RSA',
373 'keypairid': 'superKeyPair'
377 if 'usertoken' in self.tokens:
380 # Auth via email and password
381 header_data['userauthdata'] = {
382 'scheme': 'EMAIL_PASSWORD',
385 'password': self.password
389 return json.dumps(header_data)
393 def __encrypt(self, plaintext):
395 Encrypt the given Plaintext with the encryption key
397 :return: Serialized JSON String of the encryption Envelope
399 iv = get_random_bytes(16)
400 encryption_envelope = {
402 'keyid': self.esn + '_' + str(self.sequence_number),
404 'iv': base64.standard_b64encode(iv)
407 plaintext = Padding.pad(plaintext, 16)
409 cipher = AES.new(self.encryption_key, AES.MODE_CBC, iv)
410 ciphertext = cipher.encrypt(plaintext)
411 encryption_envelope['ciphertext'] = base64.standard_b64encode(ciphertext)
412 return json.dumps(encryption_envelope)
415 def __sign(self, text):
417 Calculates the HMAC signature for the given text with the current sign key and SHA256
419 :return: Base64 encoded signature
421 signature = HMAC.new(self.sign_key, text, SHA256).digest()
422 return base64.standard_b64encode(signature)
425 def __perform_key_handshake(self):
426 header = self.__generate_msl_header(is_key_request=True, is_handshake=True, compressionalgo="", encrypt=False)
434 'headerdata': base64.standard_b64encode(header),
437 self.kodi_helper.log(msg='Key Handshake Request:')
438 self.kodi_helper.log(msg=json.dumps(request))
440 resp = self.session.post(self.endpoints['manifest'], json.dumps(request, sort_keys=True))
441 if resp.status_code == 200:
443 if 'errordata' in resp:
444 self.kodi_helper.log(msg='Key Exchange failed')
445 self.kodi_helper.log(msg=base64.standard_b64decode(resp['errordata']))
447 self.__parse_crypto_keys(json.JSONDecoder().decode(base64.standard_b64decode(resp['headerdata'])))
449 self.kodi_helper.log(msg='Key Exchange failed')
450 self.kodi_helper.log(msg=resp.text)
452 def __parse_crypto_keys(self, headerdata):
453 self.__set_master_token(headerdata['keyresponsedata']['mastertoken'])
455 encrypted_encryption_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['encryptionkey'])
456 encrypted_sign_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['hmackey'])
457 cipher_rsa = PKCS1_OAEP.new(self.rsa_key)
459 # Decrypt encryption key
460 encryption_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_encryption_key))
461 self.encryption_key = base64key_decode(encryption_key_data['k'])
464 sign_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_sign_key))
465 self.sign_key = base64key_decode(sign_key_data['k'])
467 self.__save_msl_data()
468 self.handshake_performed = True
470 def __load_msl_data(self):
471 msl_data = json.JSONDecoder().decode(self.load_file(self.kodi_helper.msl_data_path, 'msl_data.json'))
472 self.__set_master_token(msl_data['tokens']['mastertoken'])
473 self.encryption_key = base64.standard_b64decode(msl_data['encryption_key'])
474 self.sign_key = base64.standard_b64decode(msl_data['sign_key'])
476 def __save_msl_data(self):
478 Saves the keys and tokens in json file
482 "encryption_key": base64.standard_b64encode(self.encryption_key),
483 'sign_key': base64.standard_b64encode(self.sign_key),
485 'mastertoken': self.mastertoken
488 serialized_data = json.JSONEncoder().encode(data)
489 self.save_file(self.kodi_helper.msl_data_path, 'msl_data.json', serialized_data)
491 def __set_master_token(self, master_token):
492 self.mastertoken = master_token
493 self.sequence_number = json.JSONDecoder().decode(base64.standard_b64decode(master_token['tokendata']))['sequencenumber']
495 def __load_rsa_keys(self):
496 loaded_key = self.load_file(self.kodi_helper.msl_data_path, 'rsa_key.bin')
497 self.rsa_key = RSA.importKey(loaded_key)
499 def __save_rsa_keys(self):
500 self.kodi_helper.log(msg='Save RSA Keys')
501 # Get the DER Base64 of the keys
502 encrypted_key = self.rsa_key.exportKey()
503 self.save_file(self.kodi_helper.msl_data_path, 'rsa_key.bin', encrypted_key)
506 def file_exists(msl_data_path, filename):
508 Checks if a given file exists
509 :param filename: The filename
512 return os.path.isfile(msl_data_path + filename)
515 def save_file(msl_data_path, filename, content):
517 Saves the given content under given filename
518 :param filename: The filename
519 :param content: The content of the file
521 with open(msl_data_path + filename, 'w') as file_:
526 def load_file(msl_data_path, filename):
528 Loads the content of a given filename
529 :param filename: The file to load
530 :return: The content of the file
532 with open(msl_data_path + filename) as file_:
533 file_content = file_.read()