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