Updated
This commit is contained in:
parent
aba5a2d349
commit
f910bf33ca
8
GPLAY - Audio Only.bat
Normal file
8
GPLAY - Audio Only.bat
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
@@ECHO OFF
|
||||||
|
|
||||||
|
set/p URL="URL: "
|
||||||
|
|
||||||
|
movie.py --url %URL% -a
|
||||||
|
pause
|
||||||
|
|
||||||
|
@@ECHO OFF
|
8
GPLAY.bat
Normal file
8
GPLAY.bat
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
@@ECHO OFF
|
||||||
|
|
||||||
|
set/p URL="URL: "
|
||||||
|
|
||||||
|
movie.py --url %URL%
|
||||||
|
pause
|
||||||
|
|
||||||
|
@@ECHO OFF
|
4
auth.json
Normal file
4
auth.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"authorization": "ya29.a0ARrdaM9BV33Jclsg1u_i-XYtanOmZ9cY4XYGJgkmzIq1CptxMIl0VS9I25lIYjPoJTs2EjtKVTZo8AHnqQdT7uRoZNiQ3XNyNdLQrPWjMcWmjnGgmFUzbgz0y1oB6ZukYEjayFTJt0x3euOmrXnGdHr-TPJQ",
|
||||||
|
"x-client-data": "CLC1yQEIkbbJAQiltskBCMS2yQEIqZ3KAQj4x8oBCIrbygEI0JrLAQioncsBCKCgywEIv6DLAQjc8ssBCKjzywE="
|
||||||
|
}
|
BIN
binaries/aria2c.exe
Normal file
BIN
binaries/aria2c.exe
Normal file
Binary file not shown.
BIN
binaries/ffmpeg.exe
Normal file
BIN
binaries/ffmpeg.exe
Normal file
Binary file not shown.
BIN
binaries/mp4decrypt.exe
Normal file
BIN
binaries/mp4decrypt.exe
Normal file
Binary file not shown.
BIN
binaries/mp4dump.exe
Normal file
BIN
binaries/mp4dump.exe
Normal file
Binary file not shown.
257
movie.py
Normal file
257
movie.py
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from colorama import init, Fore
|
||||||
|
from prettytable import PrettyTable
|
||||||
|
from pywidevine.decrypt.wvdecrypt import WvDecrypt
|
||||||
|
|
||||||
|
|
||||||
|
init(autoreset=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Main(object):
|
||||||
|
def __init__(self, folders, args):
|
||||||
|
self.folders = folders
|
||||||
|
self.args = args
|
||||||
|
self.auth_json = None
|
||||||
|
self.movie_id = args.url.split('id=')[-1]
|
||||||
|
self.movie_details = None
|
||||||
|
self.movie_resources = {}
|
||||||
|
self.mpd_representations = {'video': [], 'audio': [], 'subtitle': []}
|
||||||
|
self.license = None
|
||||||
|
|
||||||
|
def auth(self):
|
||||||
|
if os.path.exists('auth.json'):
|
||||||
|
with open('auth.json', 'r') as src:
|
||||||
|
self.auth_json = json.loads(src.read())
|
||||||
|
else:
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
def requests_headers(self):
|
||||||
|
return {
|
||||||
|
'accept': '*/*',
|
||||||
|
'accept-language': 'en-US,en;q=0.9',
|
||||||
|
'authorization': 'Bearer {0}'.format(self.auth_json['authorization']),
|
||||||
|
'Host': 'www.googleapis.com',
|
||||||
|
'origin': 'chrome-extension://gdijeikdkaembjbdobgfkoidjkpbmlkd',
|
||||||
|
'sec-fetch-dest': 'empty',
|
||||||
|
'sec-fetch-mode': 'cors',
|
||||||
|
'sec-fetch-site': 'cross-site',
|
||||||
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36',
|
||||||
|
'x-client-data': '{0}'.format(self.auth_json['x-client-data']),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_movie_details(self):
|
||||||
|
url = 'https://www.googleapis.com/android_video/v1/asset/list?id=yt%3Amovie%3A{0}&if=imbrg&lr=en_US&cr=US&alt=json&access_token={1}&make=Google&model=ChromeCDM-Windows-x86-32&product=generic&device=generic'.format(self.movie_id, self.auth_json['authorization'])
|
||||||
|
self.movie_details = requests.get(url=url, headers=self.requests_headers()).json()
|
||||||
|
|
||||||
|
def get_movie_resources(self):
|
||||||
|
url = 'https://www.googleapis.com/android_video/v1/mpd?id=yt%3Amovie%3A{0}&ac3=true&all51=true&nd=false&all=false&secure=true&msu=false&ma=true&fc=true&hdcp=true&alt={1}&ssrc=googlevideo&access_token={2}&make=Google&model=ChromeCDM-Windows-x86-32&product=generic&device=generic'
|
||||||
|
self.movie_resources['json'] = requests.get(
|
||||||
|
url = url.format(self.movie_id, 'json', self.auth_json['authorization']),
|
||||||
|
headers = self.requests_headers()
|
||||||
|
).json()
|
||||||
|
#self.movie_resources['protojson'] = requests.get(
|
||||||
|
#url = url.format(self.movie_id, 'protojson', self.auth_json['authorization']),
|
||||||
|
#headers = self.requests_headers()
|
||||||
|
#).json()
|
||||||
|
|
||||||
|
def parse_movie_resources(self):
|
||||||
|
av_representations = self.movie_resources['json']['representations']
|
||||||
|
for x in av_representations:
|
||||||
|
if 'audio_info' not in x:
|
||||||
|
self.mpd_representations['video'].append({
|
||||||
|
'playback_url': x['playback_url'],
|
||||||
|
'codec': x['codec'],
|
||||||
|
'init': x['init'],
|
||||||
|
'bitrate': x['bitrate'],
|
||||||
|
'quality': str(x['height'])+'p',
|
||||||
|
'fps': x['video_fps']
|
||||||
|
})
|
||||||
|
elif 'audio_info' in x:
|
||||||
|
self.mpd_representations['audio'].append({
|
||||||
|
'playback_url': x['playback_url'],
|
||||||
|
'codec': x['codec'],
|
||||||
|
'init': x['init'],
|
||||||
|
'bitrate': x['bitrate'],
|
||||||
|
'language': x['audio_info']['language']
|
||||||
|
})
|
||||||
|
#subtitle_representations = self.movie_resources['protojson']['1007']['4']
|
||||||
|
#for x in subtitle_representations:
|
||||||
|
#self.mpd_representations['subtitle'].append({
|
||||||
|
#'language': x['1'],
|
||||||
|
#'url': x['3'] ,
|
||||||
|
#'format': x['5']
|
||||||
|
#})
|
||||||
|
|
||||||
|
def aria2c(self, url, output_file_name):
|
||||||
|
aria2c = os.path.join(self.folders['binaries'], 'aria2c.exe')
|
||||||
|
aria2c_command = [
|
||||||
|
aria2c, url,
|
||||||
|
'-d', self.folders['temp'], '-j16',
|
||||||
|
'-o', output_file_name, '-s16', '-x16',
|
||||||
|
'-U', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36',
|
||||||
|
'--allow-overwrite=false',
|
||||||
|
'--async-dns=false',
|
||||||
|
'--auto-file-renaming=false',
|
||||||
|
'--console-log-level=warn',
|
||||||
|
'--retry-wait=5',
|
||||||
|
'--summary-interval=0',
|
||||||
|
]
|
||||||
|
subprocess.run(aria2c_command)
|
||||||
|
return os.path.join(self.folders['temp'], output_file_name)
|
||||||
|
|
||||||
|
def extract_pssh(self, mp4_file):
|
||||||
|
mp4dump = os.path.join(self.folders['binaries'], 'mp4dump.exe')
|
||||||
|
wv_system_id = '[ed ef 8b a9 79 d6 4a ce a3 c8 27 dc d5 1d 21 ed]'
|
||||||
|
pssh = None
|
||||||
|
data = subprocess.check_output([mp4dump, '--format', 'json', '--verbosity', '1', mp4_file])
|
||||||
|
data = json.loads(data)
|
||||||
|
for atom in data:
|
||||||
|
if atom['name'] == 'moov':
|
||||||
|
for child in atom['children']:
|
||||||
|
if child['name'] == 'pssh' and child['system_id'] == wv_system_id:
|
||||||
|
pssh = child['data'][1:-1].replace(' ', '')
|
||||||
|
pssh = binascii.unhexlify(pssh)
|
||||||
|
pssh = pssh[0:]
|
||||||
|
pssh = base64.b64encode(pssh).decode('utf-8')
|
||||||
|
return pssh
|
||||||
|
|
||||||
|
def license_request(self, pssh):
|
||||||
|
license_url = 'https://play.google.com/video/license/GetCencLicense?source=YOUTUBE&video_id={0}&oauth={1}'.format(self.movie_id, self.auth_json['authorization'])
|
||||||
|
license_headers = {
|
||||||
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36'
|
||||||
|
}
|
||||||
|
wvdecrypt = WvDecrypt(pssh)
|
||||||
|
challenge = wvdecrypt.get_challenge()
|
||||||
|
resp = requests.post(url=license_url, headers=license_headers, data=challenge)
|
||||||
|
resp1 = resp.content.split('\r\n\r\n'.encode('utf-8'))
|
||||||
|
resp2 = resp1[1]
|
||||||
|
license_b64 = base64.b64encode(resp2).decode('utf-8')
|
||||||
|
wvdecrypt.update_license(license_b64)
|
||||||
|
keys = wvdecrypt.start_process()
|
||||||
|
return keys
|
||||||
|
|
||||||
|
def mp4decrypt(self, keys):
|
||||||
|
mp4decrypt_command = [os.path.join(self.folders['binaries'], 'mp4decrypt.exe')]
|
||||||
|
for key in keys:
|
||||||
|
if key.type == 'CONTENT':
|
||||||
|
mp4decrypt_command.append('--show-progress')
|
||||||
|
mp4decrypt_command.append('--key')
|
||||||
|
mp4decrypt_command.append('{}:{}'.format(key.kid.hex(), key.key.hex()))
|
||||||
|
return mp4decrypt_command
|
||||||
|
|
||||||
|
def decrypt(self, keys, input, output):
|
||||||
|
mp4decrypt_command = self.mp4decrypt(keys)
|
||||||
|
mp4decrypt_command.append(input)
|
||||||
|
mp4decrypt_command.append(output)
|
||||||
|
wvdecrypt_process = subprocess.Popen(mp4decrypt_command)
|
||||||
|
wvdecrypt_process.communicate()
|
||||||
|
wvdecrypt_process.wait()
|
||||||
|
|
||||||
|
def video(self):
|
||||||
|
table = PrettyTable()
|
||||||
|
table.field_names = ['ID', 'CODEC', 'QUALITY', 'BITRATE', 'FPS']
|
||||||
|
for i, j in enumerate(self.mpd_representations['video']):
|
||||||
|
table.add_row([i, j['codec'], j['quality'], j['bitrate'], j['fps']])
|
||||||
|
print('\n' + Fore.RED + 'VIDEO')
|
||||||
|
print(table)
|
||||||
|
selected_video = self.mpd_representations['video'][int(input('ID: '))]
|
||||||
|
init_url = selected_video['playback_url'] + '?range={0}-{1}'.format(selected_video['init']['first'], selected_video['init']['last'])
|
||||||
|
self.aria2c(init_url, 'init.mp4')
|
||||||
|
selected_video['pssh'] = self.extract_pssh(os.path.join(self.folders['temp'], 'init.mp4'))
|
||||||
|
os.remove(os.path.join(self.folders['temp'], 'init.mp4'))
|
||||||
|
print(Fore.YELLOW+'\nAcquiring Content License')
|
||||||
|
self.license = self.license_request(selected_video['pssh'])
|
||||||
|
print(Fore.GREEN+'License Acquired Successfully')
|
||||||
|
print(Fore.YELLOW+'\nURL:', selected_video['playback_url'])
|
||||||
|
if not self.args.keys:
|
||||||
|
output_file_name = self.movie_details['resource'][0]['metadata']['title'] + ' ' + f'[{selected_video["quality"]}] Encrypted.mp4'
|
||||||
|
print(Fore.YELLOW+'\nDownloading', output_file_name)
|
||||||
|
video_downloaded = self.aria2c(selected_video['playback_url'], output_file_name.replace(':', ''))
|
||||||
|
print(Fore.YELLOW+'\nDecrypting Video')
|
||||||
|
self.decrypt(self.license, video_downloaded, video_downloaded.replace(' Encrypted', ''))
|
||||||
|
os.remove(video_downloaded)
|
||||||
|
else:
|
||||||
|
print(Fore.GREEN + 'n\KEYS')
|
||||||
|
for key in self.license:
|
||||||
|
if key.type == 'CONTENT':
|
||||||
|
print('{}:{}'.format(key.kid.hex(), key.key.hex()))
|
||||||
|
|
||||||
|
def audio(self):
|
||||||
|
table = PrettyTable()
|
||||||
|
table.field_names = ['ID', 'CODEC', 'BITRATE', 'LANGUAGE']
|
||||||
|
for i, j in enumerate(self.mpd_representations['audio']):
|
||||||
|
table.add_row([i, j['codec'], j['bitrate'], j['language']])
|
||||||
|
print('\n' + Fore.RED +'AUDIO')
|
||||||
|
print(table)
|
||||||
|
selected_audio = input('ID: ')
|
||||||
|
if self.args.audio:
|
||||||
|
init_url = self.mpd_representations['audio'][int(selected_audio.split(',')[-1])]['playback_url']
|
||||||
|
init_url += '?range={0}-{1}'.format(self.mpd_representations['audio'][int(selected_audio.split(',')[-1])]['init']['first'], self.mpd_representations['audio'][int(selected_audio.split(',')[-1])]['init']['last'])
|
||||||
|
self.aria2c(init_url, 'init.mp4')
|
||||||
|
pssh = self.extract_pssh(os.path.join(self.folders['temp'], 'init.mp4'))
|
||||||
|
os.remove(os.path.join(self.folders['temp'], 'init.mp4'))
|
||||||
|
print(Fore.YELLOW+'\nAcquiring Content License')
|
||||||
|
self.license = self.license_request(pssh)
|
||||||
|
print(Fore.GREEN+'License Acquired Successfully')
|
||||||
|
for x in selected_audio.split(','):
|
||||||
|
x = int(x.strip())
|
||||||
|
playback_url = self.mpd_representations['audio'][x]['playback_url']
|
||||||
|
print(Fore.YELLOW+'\nURL:', playback_url)
|
||||||
|
if not self.args.keys:
|
||||||
|
output_file_name = self.movie_details['resource'][0]['metadata']['title'] + ' ' + f'[{self.mpd_representations["audio"][x]["language"]}-{self.mpd_representations["audio"][x]["codec"]}-{self.mpd_representations["audio"][x]["bitrate"]}] Encrypted.mp4'
|
||||||
|
print(Fore.YELLOW+'\nDownloading', output_file_name)
|
||||||
|
audio_downloaded = self.aria2c(playback_url, output_file_name.replace(':', ''))
|
||||||
|
self.decrypt(self.license, audio_downloaded, audio_downloaded.replace(' Encrypted', ''))
|
||||||
|
os.remove(audio_downloaded)
|
||||||
|
else:
|
||||||
|
print(Fore.GREEN + 'n\KEYS')
|
||||||
|
for key in self.license:
|
||||||
|
if key.type == 'CONTENT':
|
||||||
|
print('{}:{}'.format(key.kid.hex(), key.key.hex()))
|
||||||
|
|
||||||
|
def subtitle(self):
|
||||||
|
table = PrettyTable()
|
||||||
|
table.field_names = ['ID', 'LANGUAGE', 'FORMAT']
|
||||||
|
for i, j in enumerate(self.mpd_representations['subtitle']):
|
||||||
|
table.add_row([i, j['language'], j['format']])
|
||||||
|
print('\n' + Fore.RED +'SUBTITLE')
|
||||||
|
print(table)
|
||||||
|
selected_subtitle = input('ID: ')
|
||||||
|
for x in selected_subtitle.split(','):
|
||||||
|
x = int(x.strip())
|
||||||
|
url = self.mpd_representations['subtitle'][x]['url']
|
||||||
|
output_file_name = self.movie_details['resource'][0]['metadata']['title'] + ' ' + f'{self.mpd_representations["subtitle"][x]["language"]}-{self.mpd_representations["subtitle"][x]["format"]}'
|
||||||
|
print(Fore.YELLOW+'\nDownloading', output_file_name)
|
||||||
|
self.aria2c(url, output_file_name)
|
||||||
|
|
||||||
|
|
||||||
|
cwd = os.getcwd()
|
||||||
|
folders = {'binaries': os.path.join(cwd, 'binaries'), 'output': os.path.join(cwd, 'output'), 'temp': os.path.join(cwd, 'temp')}
|
||||||
|
|
||||||
|
|
||||||
|
arg_parser = argparse.ArgumentParser()
|
||||||
|
arg_parser.add_argument('-u', '--url', required=True)
|
||||||
|
arg_parser.add_argument('-a', '--audio', action='store_true')
|
||||||
|
arg_parser.add_argument('-k', '--keys', action='store_true')
|
||||||
|
args = arg_parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
movie = Main(folders, args)
|
||||||
|
movie.auth()
|
||||||
|
movie.get_movie_details()
|
||||||
|
movie.get_movie_resources()
|
||||||
|
movie.parse_movie_resources()
|
||||||
|
if not args.audio:
|
||||||
|
#movie.subtitle()
|
||||||
|
movie.video()
|
||||||
|
movie.audio()
|
||||||
|
|
||||||
|
|
0
pyhls/__init__.py
Normal file
0
pyhls/__init__.py
Normal file
BIN
pyhls/__pycache__/__init__.cpython-37.pyc
Normal file
BIN
pyhls/__pycache__/__init__.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pyhls/__pycache__/__init__.cpython-38.pyc
Normal file
BIN
pyhls/__pycache__/__init__.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pyhls/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
pyhls/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
pyhls/__pycache__/m3u8_parser.cpython-37.pyc
Normal file
BIN
pyhls/__pycache__/m3u8_parser.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pyhls/__pycache__/m3u8_parser.cpython-38.pyc
Normal file
BIN
pyhls/__pycache__/m3u8_parser.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pyhls/__pycache__/m3u8_parser.cpython-39.pyc
Normal file
BIN
pyhls/__pycache__/m3u8_parser.cpython-39.pyc
Normal file
Binary file not shown.
BIN
pyhls/__pycache__/manifest_parser.cpython-37.pyc
Normal file
BIN
pyhls/__pycache__/manifest_parser.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pyhls/__pycache__/protocol.cpython-37.pyc
Normal file
BIN
pyhls/__pycache__/protocol.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pyhls/__pycache__/protocol.cpython-38.pyc
Normal file
BIN
pyhls/__pycache__/protocol.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pyhls/__pycache__/protocol.cpython-39.pyc
Normal file
BIN
pyhls/__pycache__/protocol.cpython-39.pyc
Normal file
Binary file not shown.
BIN
pyhls/__pycache__/utils.cpython-37.pyc
Normal file
BIN
pyhls/__pycache__/utils.cpython-37.pyc
Normal file
Binary file not shown.
549
pyhls/m3u8_parser.py
Normal file
549
pyhls/m3u8_parser.py
Normal file
@ -0,0 +1,549 @@
|
|||||||
|
import datetime
|
||||||
|
import iso8601
|
||||||
|
import itertools
|
||||||
|
import re
|
||||||
|
|
||||||
|
from pyhls import protocol
|
||||||
|
|
||||||
|
|
||||||
|
ATTRIBUTELISTPATTERN = re.compile(r'''((?:[^,"']|"[^"]*"|'[^']*')+)''')
|
||||||
|
|
||||||
|
|
||||||
|
def cast_date_time(value):
|
||||||
|
return iso8601.parse_date(value)
|
||||||
|
|
||||||
|
|
||||||
|
def format_date_time(value):
|
||||||
|
return value.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class ParseError(Exception):
|
||||||
|
def __init__(self, lineno, line):
|
||||||
|
self.lineno = lineno
|
||||||
|
self.line = line
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'Syntax error in manifest on line %d: %s' % (self.lineno, self.line)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_m3u8(content, strict=False, custom_tags_parser=None):
|
||||||
|
'''
|
||||||
|
Given a M3U8 playlist content returns a dictionary with all data found
|
||||||
|
'''
|
||||||
|
data = {
|
||||||
|
'media_sequence': 0,
|
||||||
|
'is_variant': False,
|
||||||
|
'is_endlist': False,
|
||||||
|
'is_i_frames_only': False,
|
||||||
|
'is_independent_segments': False,
|
||||||
|
'playlist_type': None,
|
||||||
|
'playlists': [],
|
||||||
|
'segments': [],
|
||||||
|
'iframe_playlists': [],
|
||||||
|
'media': [],
|
||||||
|
'keys': [],
|
||||||
|
'rendition_reports': [],
|
||||||
|
'skip': {},
|
||||||
|
'part_inf': {},
|
||||||
|
'session_data': [],
|
||||||
|
'session_keys': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {
|
||||||
|
'expect_segment': False,
|
||||||
|
'expect_playlist': False,
|
||||||
|
'current_key': None,
|
||||||
|
'current_segment_map': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
lineno = 0
|
||||||
|
for line in string_to_lines(content):
|
||||||
|
lineno += 1
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
if line.startswith(protocol.ext_x_byterange):
|
||||||
|
_parse_byterange(line, state)
|
||||||
|
state['expect_segment'] = True
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_targetduration):
|
||||||
|
_parse_simple_parameter(line, data, float)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_media_sequence):
|
||||||
|
_parse_simple_parameter(line, data, int)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_discontinuity_sequence):
|
||||||
|
_parse_simple_parameter(line, data, int)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_program_date_time):
|
||||||
|
_, program_date_time = _parse_simple_parameter_raw_value(line, cast_date_time)
|
||||||
|
if not data.get('program_date_time'):
|
||||||
|
data['program_date_time'] = program_date_time
|
||||||
|
state['current_program_date_time'] = program_date_time
|
||||||
|
state['program_date_time'] = program_date_time
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_discontinuity):
|
||||||
|
state['discontinuity'] = True
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_cue_out_cont):
|
||||||
|
_parse_cueout_cont(line, state)
|
||||||
|
state['cue_out'] = True
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_cue_out):
|
||||||
|
_parse_cueout(line, state, string_to_lines(content)[lineno - 2])
|
||||||
|
state['cue_out_start'] = True
|
||||||
|
state['cue_out'] = True
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_cue_in):
|
||||||
|
state['cue_in'] = True
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_cue_span):
|
||||||
|
state['cue_out'] = True
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_version):
|
||||||
|
_parse_simple_parameter(line, data, int)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_allow_cache):
|
||||||
|
_parse_simple_parameter(line, data)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_key):
|
||||||
|
key = _parse_key(line)
|
||||||
|
state['current_key'] = key
|
||||||
|
if key not in data['keys']:
|
||||||
|
data['keys'].append(key)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.extinf):
|
||||||
|
_parse_extinf(line, data, state, lineno, strict)
|
||||||
|
state['expect_segment'] = True
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_stream_inf):
|
||||||
|
state['expect_playlist'] = True
|
||||||
|
_parse_stream_inf(line, data, state)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_i_frame_stream_inf):
|
||||||
|
_parse_i_frame_stream_inf(line, data)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_media):
|
||||||
|
_parse_media(line, data, state)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_playlist_type):
|
||||||
|
_parse_simple_parameter(line, data)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_i_frames_only):
|
||||||
|
data['is_i_frames_only'] = True
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_is_independent_segments):
|
||||||
|
data['is_independent_segments'] = True
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_endlist):
|
||||||
|
data['is_endlist'] = True
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_map):
|
||||||
|
quoted_parser = remove_quotes_parser('uri')
|
||||||
|
segment_map_info = _parse_attribute_list(protocol.ext_x_map, line, quoted_parser)
|
||||||
|
state['current_segment_map'] = segment_map_info
|
||||||
|
# left for backward compatibility
|
||||||
|
data['segment_map'] = segment_map_info
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_start):
|
||||||
|
attribute_parser = {
|
||||||
|
"time_offset": lambda x: float(x)
|
||||||
|
}
|
||||||
|
start_info = _parse_attribute_list(protocol.ext_x_start, line, attribute_parser)
|
||||||
|
data['start'] = start_info
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_server_control):
|
||||||
|
_parse_server_control(line, data, state)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_part_inf):
|
||||||
|
_parse_part_inf(line, data, state)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_rendition_report):
|
||||||
|
_parse_rendition_report(line, data, state)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_part):
|
||||||
|
_parse_part(line, data, state)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_skip):
|
||||||
|
_parse_skip(line, data, state)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_session_data):
|
||||||
|
_parse_session_data(line, data, state)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_session_key):
|
||||||
|
_parse_session_key(line, data, state)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_preload_hint):
|
||||||
|
_parse_preload_hint(line, data, state)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_daterange):
|
||||||
|
_parse_daterange(line, data, state)
|
||||||
|
|
||||||
|
elif line.startswith(protocol.ext_x_gap):
|
||||||
|
state['gap'] = True
|
||||||
|
|
||||||
|
# Comments and whitespace
|
||||||
|
elif line.startswith('#'):
|
||||||
|
if callable(custom_tags_parser):
|
||||||
|
custom_tags_parser(line, data, lineno)
|
||||||
|
|
||||||
|
elif line.strip() == '':
|
||||||
|
# blank lines are legal
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif state['expect_segment']:
|
||||||
|
_parse_ts_chunk(line, data, state)
|
||||||
|
state['expect_segment'] = False
|
||||||
|
|
||||||
|
elif state['expect_playlist']:
|
||||||
|
_parse_variant_playlist(line, data, state)
|
||||||
|
state['expect_playlist'] = False
|
||||||
|
|
||||||
|
elif strict:
|
||||||
|
raise ParseError(lineno, line)
|
||||||
|
|
||||||
|
# there could be remaining partial segments
|
||||||
|
if 'segment' in state:
|
||||||
|
data['segments'].append(state.pop('segment'))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_key(line):
|
||||||
|
params = ATTRIBUTELISTPATTERN.split(line.replace(protocol.ext_x_key + ':', ''))[1::2]
|
||||||
|
key = {}
|
||||||
|
for param in params:
|
||||||
|
name, value = param.split('=', 1)
|
||||||
|
key[normalize_attribute(name)] = remove_quotes(value)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_extinf(line, data, state, lineno, strict):
|
||||||
|
chunks = line.replace(protocol.extinf + ':', '').split(',', 1)
|
||||||
|
if len(chunks) == 2:
|
||||||
|
duration, title = chunks
|
||||||
|
elif len(chunks) == 1:
|
||||||
|
if strict:
|
||||||
|
raise ParseError(lineno, line)
|
||||||
|
else:
|
||||||
|
duration = chunks[0]
|
||||||
|
title = ''
|
||||||
|
if 'segment' not in state:
|
||||||
|
state['segment'] = {}
|
||||||
|
state['segment']['duration'] = float(duration)
|
||||||
|
state['segment']['title'] = title
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts_chunk(line, data, state):
|
||||||
|
segment = state.pop('segment')
|
||||||
|
if state.get('program_date_time'):
|
||||||
|
segment['program_date_time'] = state.pop('program_date_time')
|
||||||
|
if state.get('current_program_date_time'):
|
||||||
|
segment['current_program_date_time'] = state['current_program_date_time']
|
||||||
|
state['current_program_date_time'] += datetime.timedelta(seconds=segment['duration'])
|
||||||
|
segment['uri'] = line
|
||||||
|
segment['cue_in'] = state.pop('cue_in', False)
|
||||||
|
segment['cue_out'] = state.pop('cue_out', False)
|
||||||
|
segment['cue_out_start'] = state.pop('cue_out_start', False)
|
||||||
|
if state.get('current_cue_out_scte35'):
|
||||||
|
segment['scte35'] = state['current_cue_out_scte35']
|
||||||
|
if state.get('current_cue_out_duration'):
|
||||||
|
segment['scte35_duration'] = state['current_cue_out_duration']
|
||||||
|
segment['discontinuity'] = state.pop('discontinuity', False)
|
||||||
|
if state.get('current_key'):
|
||||||
|
segment['key'] = state['current_key']
|
||||||
|
else:
|
||||||
|
# For unencrypted segments, the initial key would be None
|
||||||
|
if None not in data['keys']:
|
||||||
|
data['keys'].append(None)
|
||||||
|
if state.get('current_segment_map'):
|
||||||
|
segment['init_section'] = state['current_segment_map']
|
||||||
|
segment['dateranges'] = state.pop('dateranges', None)
|
||||||
|
segment['gap_tag'] = state.pop('gap', None)
|
||||||
|
data['segments'].append(segment)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_attribute_list(prefix, line, atribute_parser):
|
||||||
|
params = ATTRIBUTELISTPATTERN.split(line.replace(prefix + ':', ''))[1::2]
|
||||||
|
|
||||||
|
attributes = {}
|
||||||
|
for param in params:
|
||||||
|
name, value = param.split('=', 1)
|
||||||
|
name = normalize_attribute(name)
|
||||||
|
|
||||||
|
if name in atribute_parser:
|
||||||
|
value = atribute_parser[name](value)
|
||||||
|
|
||||||
|
attributes[name] = value
|
||||||
|
|
||||||
|
return attributes
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_stream_inf(line, data, state):
|
||||||
|
data['is_variant'] = True
|
||||||
|
data['media_sequence'] = None
|
||||||
|
atribute_parser = remove_quotes_parser('codecs', 'audio', 'video', 'subtitles', 'closed_captions')
|
||||||
|
atribute_parser["program_id"] = int
|
||||||
|
atribute_parser["bandwidth"] = lambda x: int(float(x))
|
||||||
|
atribute_parser["average_bandwidth"] = int
|
||||||
|
atribute_parser["frame_rate"] = float
|
||||||
|
atribute_parser["video_range"] = str
|
||||||
|
state['stream_info'] = _parse_attribute_list(protocol.ext_x_stream_inf, line, atribute_parser)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_i_frame_stream_inf(line, data):
|
||||||
|
atribute_parser = remove_quotes_parser('codecs', 'uri')
|
||||||
|
atribute_parser["program_id"] = int
|
||||||
|
atribute_parser["bandwidth"] = int
|
||||||
|
atribute_parser["average_bandwidth"] = int
|
||||||
|
atribute_parser["video_range"] = str
|
||||||
|
iframe_stream_info = _parse_attribute_list(protocol.ext_x_i_frame_stream_inf, line, atribute_parser)
|
||||||
|
iframe_playlist = {'uri': iframe_stream_info.pop('uri'),
|
||||||
|
'iframe_stream_info': iframe_stream_info}
|
||||||
|
|
||||||
|
data['iframe_playlists'].append(iframe_playlist)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_media(line, data, state):
|
||||||
|
quoted = remove_quotes_parser('uri', 'group_id', 'language', 'assoc_language', 'name', 'instream_id', 'characteristics', 'channels')
|
||||||
|
media = _parse_attribute_list(protocol.ext_x_media, line, quoted)
|
||||||
|
data['media'].append(media)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_variant_playlist(line, data, state):
|
||||||
|
playlist = {'uri': line,
|
||||||
|
'stream_info': state.pop('stream_info')}
|
||||||
|
|
||||||
|
data['playlists'].append(playlist)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_byterange(line, state):
|
||||||
|
if 'segment' not in state:
|
||||||
|
state['segment'] = {}
|
||||||
|
state['segment']['byterange'] = line.replace(protocol.ext_x_byterange + ':', '')
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_simple_parameter_raw_value(line, cast_to=str, normalize=False):
|
||||||
|
param, value = line.split(':', 1)
|
||||||
|
param = normalize_attribute(param.replace('#EXT-X-', ''))
|
||||||
|
if normalize:
|
||||||
|
value = value.strip().lower()
|
||||||
|
return param, cast_to(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_and_set_simple_parameter_raw_value(line, data, cast_to=str, normalize=False):
|
||||||
|
param, value = _parse_simple_parameter_raw_value(line, cast_to, normalize)
|
||||||
|
data[param] = value
|
||||||
|
return data[param]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_simple_parameter(line, data, cast_to=str):
|
||||||
|
return _parse_and_set_simple_parameter_raw_value(line, data, cast_to, True)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cueout_cont(line, state):
|
||||||
|
param, value = line.split(':', 1)
|
||||||
|
res = re.match('.*Duration=(.*),SCTE35=(.*)$', value)
|
||||||
|
if res:
|
||||||
|
state['current_cue_out_duration'] = res.group(1)
|
||||||
|
state['current_cue_out_scte35'] = res.group(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _cueout_no_duration(line):
|
||||||
|
# this needs to be called first since line.split in all other
|
||||||
|
# parsers will throw a ValueError if passed just this tag
|
||||||
|
if line == protocol.ext_x_cue_out:
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _cueout_elemental(line, state, prevline):
|
||||||
|
param, value = line.split(':', 1)
|
||||||
|
res = re.match('.*EXT-OATCLS-SCTE35:(.*)$', prevline)
|
||||||
|
if res:
|
||||||
|
return (res.group(1), value)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cueout_envivio(line, state, prevline):
|
||||||
|
param, value = line.split(':', 1)
|
||||||
|
res = re.match('.*DURATION=(.*),.*,CUE="(.*)"', value)
|
||||||
|
if res:
|
||||||
|
return (res.group(2), res.group(1))
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cueout_duration(line):
|
||||||
|
# this needs to be called after _cueout_elemental
|
||||||
|
# as it would capture those cues incompletely
|
||||||
|
# This was added seperately rather than modifying "simple"
|
||||||
|
param, value = line.split(':', 1)
|
||||||
|
res = re.match(r'DURATION=(.*)', value)
|
||||||
|
if res:
|
||||||
|
return (None, res.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def _cueout_simple(line):
|
||||||
|
# this needs to be called after _cueout_elemental
|
||||||
|
# as it would capture those cues incompletely
|
||||||
|
param, value = line.split(':', 1)
|
||||||
|
res = re.match(r'^(\d+(?:\.\d)?\d*)$', value)
|
||||||
|
if res:
|
||||||
|
return (None, res.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cueout(line, state, prevline):
|
||||||
|
_cueout_state = (_cueout_no_duration(line)
|
||||||
|
or _cueout_elemental(line, state, prevline)
|
||||||
|
or _cueout_envivio(line, state, prevline)
|
||||||
|
or _cueout_duration(line)
|
||||||
|
or _cueout_simple(line))
|
||||||
|
if _cueout_state:
|
||||||
|
state['current_cue_out_scte35'] = _cueout_state[0]
|
||||||
|
state['current_cue_out_duration'] = _cueout_state[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_server_control(line, data, state):
|
||||||
|
attribute_parser = {
|
||||||
|
"can_block_reload": str,
|
||||||
|
"hold_back": lambda x: float(x),
|
||||||
|
"part_hold_back": lambda x: float(x),
|
||||||
|
"can_skip_until": lambda x: float(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
data['server_control'] = _parse_attribute_list(
|
||||||
|
protocol.ext_x_server_control, line, attribute_parser
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_part_inf(line, data, state):
|
||||||
|
attribute_parser = {
|
||||||
|
"part_target": lambda x: float(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
data['part_inf'] = _parse_attribute_list(
|
||||||
|
protocol.ext_x_part_inf, line, attribute_parser
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rendition_report(line, data, state):
|
||||||
|
attribute_parser = remove_quotes_parser('uri')
|
||||||
|
attribute_parser['last_msn'] = int
|
||||||
|
attribute_parser['last_part'] = int
|
||||||
|
|
||||||
|
rendition_report = _parse_attribute_list(
|
||||||
|
protocol.ext_x_rendition_report, line, attribute_parser
|
||||||
|
)
|
||||||
|
|
||||||
|
data['rendition_reports'].append(rendition_report)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_part(line, data, state):
|
||||||
|
attribute_parser = remove_quotes_parser('uri')
|
||||||
|
attribute_parser['duration'] = lambda x: float(x)
|
||||||
|
attribute_parser['independent'] = str
|
||||||
|
attribute_parser['gap'] = str
|
||||||
|
attribute_parser['byterange'] = str
|
||||||
|
|
||||||
|
part = _parse_attribute_list(protocol.ext_x_part, line, attribute_parser)
|
||||||
|
|
||||||
|
# this should always be true according to spec
|
||||||
|
if state.get('current_program_date_time'):
|
||||||
|
part['program_date_time'] = state['current_program_date_time']
|
||||||
|
state['current_program_date_time'] += datetime.timedelta(seconds=part['duration'])
|
||||||
|
|
||||||
|
part['dateranges'] = state.pop('dateranges', None)
|
||||||
|
part['gap_tag'] = state.pop('gap', None)
|
||||||
|
|
||||||
|
if 'segment' not in state:
|
||||||
|
state['segment'] = {}
|
||||||
|
segment = state['segment']
|
||||||
|
if 'parts' not in segment:
|
||||||
|
segment['parts'] = []
|
||||||
|
|
||||||
|
segment['parts'].append(part)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_skip(line, data, state):
|
||||||
|
attribute_parser = {
|
||||||
|
"skipped_segments": int
|
||||||
|
}
|
||||||
|
|
||||||
|
data['skip'] = _parse_attribute_list(protocol.ext_x_skip, line, attribute_parser)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_session_data(line, data, state):
|
||||||
|
quoted = remove_quotes_parser('data_id', 'value', 'uri', 'language')
|
||||||
|
session_data = _parse_attribute_list(protocol.ext_x_session_data, line, quoted)
|
||||||
|
data['session_data'].append(session_data)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_session_key(line, data, state):
|
||||||
|
params = ATTRIBUTELISTPATTERN.split(line.replace(protocol.ext_x_session_key + ':', ''))[1::2]
|
||||||
|
key = {}
|
||||||
|
for param in params:
|
||||||
|
name, value = param.split('=', 1)
|
||||||
|
key[normalize_attribute(name)] = remove_quotes(value)
|
||||||
|
data['session_keys'].append(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_preload_hint(line, data, state):
|
||||||
|
attribute_parser = remove_quotes_parser('uri')
|
||||||
|
attribute_parser['type'] = str
|
||||||
|
attribute_parser['byterange_start'] = int
|
||||||
|
attribute_parser['byterange_length'] = int
|
||||||
|
|
||||||
|
data['preload_hint'] = _parse_attribute_list(
|
||||||
|
protocol.ext_x_preload_hint, line, attribute_parser
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_daterange(line, date, state):
|
||||||
|
attribute_parser = remove_quotes_parser('id', 'class', 'start_date', 'end_date')
|
||||||
|
attribute_parser['duration'] = float
|
||||||
|
attribute_parser['planned_duration'] = float
|
||||||
|
attribute_parser['end_on_next'] = str
|
||||||
|
attribute_parser['scte35_cmd'] = str
|
||||||
|
attribute_parser['scte35_out'] = str
|
||||||
|
attribute_parser['scte35_in'] = str
|
||||||
|
|
||||||
|
parsed = _parse_attribute_list(
|
||||||
|
protocol.ext_x_daterange, line, attribute_parser
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'dateranges' not in state:
|
||||||
|
state['dateranges'] = []
|
||||||
|
|
||||||
|
state['dateranges'].append(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def string_to_lines(string):
|
||||||
|
return string.strip().splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def remove_quotes_parser(*attrs):
|
||||||
|
return dict(zip(attrs, itertools.repeat(remove_quotes)))
|
||||||
|
|
||||||
|
|
||||||
|
def remove_quotes(string):
|
||||||
|
'''
|
||||||
|
Remove quotes from string.
|
||||||
|
|
||||||
|
Ex.:
|
||||||
|
"foo" -> foo
|
||||||
|
'foo' -> foo
|
||||||
|
'foo -> 'foo
|
||||||
|
|
||||||
|
'''
|
||||||
|
quotes = ('"', "'")
|
||||||
|
if string.startswith(quotes) and string.endswith(quotes):
|
||||||
|
return string[1:-1]
|
||||||
|
return string
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_attribute(attribute):
|
||||||
|
return attribute.replace('-', '_').lower().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_url(uri):
|
||||||
|
return uri.startswith(('https://', 'http://'))
|
34
pyhls/protocol.py
Normal file
34
pyhls/protocol.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
ext_x_targetduration = '#EXT-X-TARGETDURATION'
|
||||||
|
ext_x_media_sequence = '#EXT-X-MEDIA-SEQUENCE'
|
||||||
|
ext_x_discontinuity_sequence = '#EXT-X-DISCONTINUITY-SEQUENCE'
|
||||||
|
ext_x_program_date_time = '#EXT-X-PROGRAM-DATE-TIME'
|
||||||
|
ext_x_media = '#EXT-X-MEDIA'
|
||||||
|
ext_x_playlist_type = '#EXT-X-PLAYLIST-TYPE'
|
||||||
|
ext_x_key = '#EXT-X-KEY'
|
||||||
|
ext_x_stream_inf = '#EXT-X-STREAM-INF'
|
||||||
|
ext_x_version = '#EXT-X-VERSION'
|
||||||
|
ext_x_allow_cache = '#EXT-X-ALLOW-CACHE'
|
||||||
|
ext_x_endlist = '#EXT-X-ENDLIST'
|
||||||
|
extinf = '#EXTINF'
|
||||||
|
ext_i_frames_only = '#EXT-X-I-FRAMES-ONLY'
|
||||||
|
ext_x_byterange = '#EXT-X-BYTERANGE'
|
||||||
|
ext_x_i_frame_stream_inf = '#EXT-X-I-FRAME-STREAM-INF'
|
||||||
|
ext_x_discontinuity = '#EXT-X-DISCONTINUITY'
|
||||||
|
ext_x_cue_out = '#EXT-X-CUE-OUT'
|
||||||
|
ext_x_cue_out_cont = '#EXT-X-CUE-OUT-CONT'
|
||||||
|
ext_x_cue_in = '#EXT-X-CUE-IN'
|
||||||
|
ext_x_cue_span = '#EXT-X-CUE-SPAN'
|
||||||
|
ext_x_scte35 = '#EXT-OATCLS-SCTE35'
|
||||||
|
ext_is_independent_segments = '#EXT-X-INDEPENDENT-SEGMENTS'
|
||||||
|
ext_x_map = '#EXT-X-MAP'
|
||||||
|
ext_x_start = '#EXT-X-START'
|
||||||
|
ext_x_server_control = '#EXT-X-SERVER-CONTROL'
|
||||||
|
ext_x_part_inf = '#EXT-X-PART-INF'
|
||||||
|
ext_x_part = '#EXT-X-PART'
|
||||||
|
ext_x_rendition_report = '#EXT-X-RENDITION-REPORT'
|
||||||
|
ext_x_skip = '#EXT-X-SKIP'
|
||||||
|
ext_x_session_data = '#EXT-X-SESSION-DATA'
|
||||||
|
ext_x_session_key = '#EXT-X-SESSION-KEY'
|
||||||
|
ext_x_preload_hint = '#EXT-X-PRELOAD-HINT'
|
||||||
|
ext_x_daterange = "#EXT-X-DATERANGE"
|
||||||
|
ext_x_gap = "#EXT-X-GAP"
|
0
pywidevine/__init__.py
Normal file
0
pywidevine/__init__.py
Normal file
BIN
pywidevine/__pycache__/__init__.cpython-36.pyc
Normal file
BIN
pywidevine/__pycache__/__init__.cpython-36.pyc
Normal file
Binary file not shown.
BIN
pywidevine/__pycache__/__init__.cpython-37.pyc
Normal file
BIN
pywidevine/__pycache__/__init__.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pywidevine/__pycache__/__init__.cpython-38.pyc
Normal file
BIN
pywidevine/__pycache__/__init__.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pywidevine/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
pywidevine/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
0
pywidevine/cdm/__init__.py
Normal file
0
pywidevine/cdm/__init__.py
Normal file
BIN
pywidevine/cdm/__pycache__/__init__.cpython-36.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/__init__.cpython-36.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/__init__.cpython-37.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/__init__.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/__init__.cpython-38.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/__init__.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/cdm.cpython-36.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/cdm.cpython-36.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/cdm.cpython-37.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/cdm.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/cdm.cpython-38.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/cdm.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/cdm.cpython-39.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/cdm.cpython-39.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/deviceconfig.cpython-36.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/deviceconfig.cpython-36.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/deviceconfig.cpython-37.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/deviceconfig.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/deviceconfig.cpython-38.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/deviceconfig.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/deviceconfig.cpython-39.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/deviceconfig.cpython-39.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/key.cpython-36.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/key.cpython-36.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/key.cpython-37.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/key.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/key.cpython-38.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/key.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/key.cpython-39.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/key.cpython-39.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/session.cpython-36.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/session.cpython-36.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/session.cpython-37.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/session.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/session.cpython-38.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/session.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/__pycache__/session.cpython-39.pyc
Normal file
BIN
pywidevine/cdm/__pycache__/session.cpython-39.pyc
Normal file
Binary file not shown.
362
pywidevine/cdm/cdm.py
Normal file
362
pywidevine/cdm/cdm.py
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import binascii
|
||||||
|
|
||||||
|
from google.protobuf.message import DecodeError
|
||||||
|
from google.protobuf import text_format
|
||||||
|
|
||||||
|
from pywidevine.cdm.formats import wv_proto2_pb2 as wv_proto2
|
||||||
|
from pywidevine.cdm.session import Session
|
||||||
|
from pywidevine.cdm.key import Key
|
||||||
|
from Cryptodome.Random import get_random_bytes
|
||||||
|
from Cryptodome.Random import random
|
||||||
|
from Cryptodome.Cipher import PKCS1_OAEP, AES
|
||||||
|
from Cryptodome.Hash import CMAC, SHA256, HMAC, SHA1
|
||||||
|
from Cryptodome.PublicKey import RSA
|
||||||
|
from Cryptodome.Signature import pss
|
||||||
|
from Cryptodome.Util import Padding
|
||||||
|
import logging
|
||||||
|
|
||||||
|
class Cdm:
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.sessions = {}
|
||||||
|
|
||||||
|
def open_session(self, init_data_b64, device, raw_init_data = None, offline=False):
|
||||||
|
self.logger.debug("open_session(init_data_b64={}, device={}".format(init_data_b64, device))
|
||||||
|
self.logger.info("opening new cdm session")
|
||||||
|
if device.session_id_type == 'android':
|
||||||
|
# format: 16 random hexdigits, 2 digit counter, 14 0s
|
||||||
|
rand_ascii = ''.join(random.choice('ABCDEF0123456789') for _ in range(16))
|
||||||
|
counter = '01' # this resets regularly so its fine to use 01
|
||||||
|
rest = '00000000000000'
|
||||||
|
session_id = rand_ascii + counter + rest
|
||||||
|
session_id = session_id.encode('ascii')
|
||||||
|
elif device.session_id_type == 'chrome':
|
||||||
|
rand_bytes = get_random_bytes(16)
|
||||||
|
session_id = rand_bytes
|
||||||
|
else:
|
||||||
|
# other formats NYI
|
||||||
|
self.logger.error("device type is unusable")
|
||||||
|
return 1
|
||||||
|
if raw_init_data and isinstance(raw_init_data, (bytes, bytearray)):
|
||||||
|
# used for NF key exchange, where they don't provide a valid PSSH
|
||||||
|
init_data = raw_init_data
|
||||||
|
self.raw_pssh = True
|
||||||
|
else:
|
||||||
|
init_data = self._parse_init_data(init_data_b64)
|
||||||
|
self.raw_pssh = False
|
||||||
|
|
||||||
|
if init_data:
|
||||||
|
new_session = Session(session_id, init_data, device, offline)
|
||||||
|
else:
|
||||||
|
self.logger.error("unable to parse init data")
|
||||||
|
return 1
|
||||||
|
self.sessions[session_id] = new_session
|
||||||
|
self.logger.info("session opened and init data parsed successfully")
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
def _parse_init_data(self, init_data_b64):
|
||||||
|
parsed_init_data = wv_proto2.WidevineCencHeader()
|
||||||
|
try:
|
||||||
|
self.logger.debug("trying to parse init_data directly")
|
||||||
|
parsed_init_data.ParseFromString(base64.b64decode(init_data_b64)[32:])
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.debug("unable to parse as-is, trying with removed pssh box header")
|
||||||
|
try:
|
||||||
|
id_bytes = parsed_init_data.ParseFromString(base64.b64decode(init_data_b64)[32:])
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.error("unable to parse, unsupported init data format")
|
||||||
|
return None
|
||||||
|
self.logger.debug("init_data:")
|
||||||
|
for line in text_format.MessageToString(parsed_init_data).splitlines():
|
||||||
|
self.logger.debug(line)
|
||||||
|
return parsed_init_data
|
||||||
|
|
||||||
|
def close_session(self, session_id):
|
||||||
|
self.logger.debug("close_session(session_id={})".format(session_id))
|
||||||
|
self.logger.info("closing cdm session")
|
||||||
|
if session_id in self.sessions:
|
||||||
|
self.sessions.pop(session_id)
|
||||||
|
self.logger.info("cdm session closed")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
self.logger.info("session {} not found".format(session_id))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def set_service_certificate(self, session_id, cert_b64):
|
||||||
|
self.logger.debug("set_service_certificate(session_id={}, cert={})".format(session_id, cert_b64))
|
||||||
|
self.logger.info("setting service certificate")
|
||||||
|
|
||||||
|
if session_id not in self.sessions:
|
||||||
|
self.logger.error("session id doesn't exist")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
session = self.sessions[session_id]
|
||||||
|
|
||||||
|
message = wv_proto2.SignedMessage()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message.ParseFromString(base64.b64decode(cert_b64))
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.error("failed to parse cert as SignedMessage")
|
||||||
|
|
||||||
|
service_certificate = wv_proto2.SignedDeviceCertificate()
|
||||||
|
|
||||||
|
if message.Type:
|
||||||
|
self.logger.debug("service cert provided as signedmessage")
|
||||||
|
try:
|
||||||
|
service_certificate.ParseFromString(message.Msg)
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.error("failed to parse service certificate")
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
self.logger.debug("service cert provided as signeddevicecertificate")
|
||||||
|
try:
|
||||||
|
service_certificate.ParseFromString(base64.b64decode(cert_b64))
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.error("failed to parse service certificate")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
self.logger.debug("service certificate:")
|
||||||
|
for line in text_format.MessageToString(service_certificate).splitlines():
|
||||||
|
self.logger.debug(line)
|
||||||
|
|
||||||
|
session.service_certificate = service_certificate
|
||||||
|
session.privacy_mode = True
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_license_request(self, session_id):
|
||||||
|
self.logger.debug("get_license_request(session_id={})".format(session_id))
|
||||||
|
self.logger.info("getting license request")
|
||||||
|
|
||||||
|
if session_id not in self.sessions:
|
||||||
|
self.logger.error("session ID does not exist")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
session = self.sessions[session_id]
|
||||||
|
|
||||||
|
# raw pssh will be treated as bytes and not parsed
|
||||||
|
if self.raw_pssh:
|
||||||
|
license_request = wv_proto2.SignedLicenseRequestRaw()
|
||||||
|
else:
|
||||||
|
license_request = wv_proto2.SignedLicenseRequest()
|
||||||
|
client_id = wv_proto2.ClientIdentification()
|
||||||
|
|
||||||
|
if not os.path.exists(session.device_config.device_client_id_blob_filename):
|
||||||
|
self.logger.error("no client ID blob available for this device")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
with open(session.device_config.device_client_id_blob_filename, "rb") as f:
|
||||||
|
try:
|
||||||
|
cid_bytes = client_id.ParseFromString(f.read())
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.error("client id failed to parse as protobuf")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
self.logger.debug("building license request")
|
||||||
|
if not self.raw_pssh:
|
||||||
|
license_request.Type = wv_proto2.SignedLicenseRequest.MessageType.Value('LICENSE_REQUEST')
|
||||||
|
license_request.Msg.ContentId.CencId.Pssh.CopyFrom(session.init_data)
|
||||||
|
else:
|
||||||
|
license_request.Type = wv_proto2.SignedLicenseRequestRaw.MessageType.Value('LICENSE_REQUEST')
|
||||||
|
license_request.Msg.ContentId.CencId.Pssh = session.init_data # bytes
|
||||||
|
|
||||||
|
if session.offline:
|
||||||
|
license_type = wv_proto2.LicenseType.Value('OFFLINE')
|
||||||
|
else:
|
||||||
|
license_type = wv_proto2.LicenseType.Value('DEFAULT')
|
||||||
|
license_request.Msg.ContentId.CencId.LicenseType = license_type
|
||||||
|
license_request.Msg.ContentId.CencId.RequestId = session_id
|
||||||
|
license_request.Msg.Type = wv_proto2.LicenseRequest.RequestType.Value('NEW')
|
||||||
|
license_request.Msg.RequestTime = int(time.time())
|
||||||
|
license_request.Msg.ProtocolVersion = wv_proto2.ProtocolVersion.Value('CURRENT')
|
||||||
|
if session.device_config.send_key_control_nonce:
|
||||||
|
license_request.Msg.KeyControlNonce = random.randrange(1, 2**31)
|
||||||
|
|
||||||
|
if session.privacy_mode:
|
||||||
|
if session.device_config.vmp:
|
||||||
|
self.logger.debug("vmp required, adding to client_id")
|
||||||
|
self.logger.debug("reading vmp hashes")
|
||||||
|
vmp_hashes = wv_proto2.FileHashes()
|
||||||
|
with open(session.device_config.device_vmp_blob_filename, "rb") as f:
|
||||||
|
try:
|
||||||
|
vmp_bytes = vmp_hashes.ParseFromString(f.read())
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.error("vmp hashes failed to parse as protobuf")
|
||||||
|
return 1
|
||||||
|
client_id._FileHashes.CopyFrom(vmp_hashes)
|
||||||
|
self.logger.debug("privacy mode & service certificate loaded, encrypting client id")
|
||||||
|
self.logger.debug("unencrypted client id:")
|
||||||
|
for line in text_format.MessageToString(client_id).splitlines():
|
||||||
|
self.logger.debug(line)
|
||||||
|
cid_aes_key = get_random_bytes(16)
|
||||||
|
cid_iv = get_random_bytes(16)
|
||||||
|
|
||||||
|
cid_cipher = AES.new(cid_aes_key, AES.MODE_CBC, cid_iv)
|
||||||
|
|
||||||
|
encrypted_client_id = cid_cipher.encrypt(Padding.pad(client_id.SerializeToString(), 16))
|
||||||
|
|
||||||
|
service_public_key = RSA.importKey(session.service_certificate._DeviceCertificate.PublicKey)
|
||||||
|
|
||||||
|
service_cipher = PKCS1_OAEP.new(service_public_key)
|
||||||
|
|
||||||
|
encrypted_cid_key = service_cipher.encrypt(cid_aes_key)
|
||||||
|
|
||||||
|
encrypted_client_id_proto = wv_proto2.EncryptedClientIdentification()
|
||||||
|
|
||||||
|
encrypted_client_id_proto.ServiceId = session.service_certificate._DeviceCertificate.ServiceId
|
||||||
|
encrypted_client_id_proto.ServiceCertificateSerialNumber = session.service_certificate._DeviceCertificate.SerialNumber
|
||||||
|
encrypted_client_id_proto.EncryptedClientId = encrypted_client_id
|
||||||
|
encrypted_client_id_proto.EncryptedClientIdIv = cid_iv
|
||||||
|
encrypted_client_id_proto.EncryptedPrivacyKey = encrypted_cid_key
|
||||||
|
|
||||||
|
license_request.Msg.EncryptedClientId.CopyFrom(encrypted_client_id_proto)
|
||||||
|
else:
|
||||||
|
license_request.Msg.ClientId.CopyFrom(client_id)
|
||||||
|
|
||||||
|
if session.device_config.private_key_available:
|
||||||
|
key = RSA.importKey(open(session.device_config.device_private_key_filename).read())
|
||||||
|
session.device_key = key
|
||||||
|
else:
|
||||||
|
self.logger.error("need device private key, other methods unimplemented")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
self.logger.debug("signing license request")
|
||||||
|
|
||||||
|
hash = SHA1.new(license_request.Msg.SerializeToString())
|
||||||
|
signature = pss.new(key).sign(hash)
|
||||||
|
|
||||||
|
license_request.Signature = signature
|
||||||
|
|
||||||
|
session.license_request = license_request
|
||||||
|
|
||||||
|
self.logger.debug("license request:")
|
||||||
|
for line in text_format.MessageToString(session.license_request).splitlines():
|
||||||
|
self.logger.debug(line)
|
||||||
|
self.logger.info("license request created")
|
||||||
|
self.logger.debug("license request b64: {}".format(base64.b64encode(license_request.SerializeToString())))
|
||||||
|
return license_request.SerializeToString()
|
||||||
|
|
||||||
|
def provide_license(self, session_id, license_b64):
|
||||||
|
self.logger.debug("provide_license(session_id={}, license_b64={})".format(session_id, license_b64))
|
||||||
|
self.logger.info("decrypting provided license")
|
||||||
|
|
||||||
|
if session_id not in self.sessions:
|
||||||
|
self.logger.error("session does not exist")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
session = self.sessions[session_id]
|
||||||
|
|
||||||
|
if not session.license_request:
|
||||||
|
self.logger.error("generate a license request first!")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
license = wv_proto2.SignedLicense()
|
||||||
|
try:
|
||||||
|
license.ParseFromString(base64.b64decode(license_b64))
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.error("unable to parse license - check protobufs")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
session.license = license
|
||||||
|
|
||||||
|
self.logger.debug("license:")
|
||||||
|
for line in text_format.MessageToString(license).splitlines():
|
||||||
|
self.logger.debug(line)
|
||||||
|
|
||||||
|
self.logger.debug("deriving keys from session key")
|
||||||
|
|
||||||
|
oaep_cipher = PKCS1_OAEP.new(session.device_key)
|
||||||
|
|
||||||
|
session.session_key = oaep_cipher.decrypt(license.SessionKey)
|
||||||
|
|
||||||
|
lic_req_msg = session.license_request.Msg.SerializeToString()
|
||||||
|
|
||||||
|
enc_key_base = b"ENCRYPTION\000" + lic_req_msg + b"\0\0\0\x80"
|
||||||
|
auth_key_base = b"AUTHENTICATION\0" + lic_req_msg + b"\0\0\2\0"
|
||||||
|
|
||||||
|
enc_key = b"\x01" + enc_key_base
|
||||||
|
auth_key_1 = b"\x01" + auth_key_base
|
||||||
|
auth_key_2 = b"\x02" + auth_key_base
|
||||||
|
auth_key_3 = b"\x03" + auth_key_base
|
||||||
|
auth_key_4 = b"\x04" + auth_key_base
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(enc_key)
|
||||||
|
|
||||||
|
enc_cmac_key = cmac_obj.digest()
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(auth_key_1)
|
||||||
|
auth_cmac_key_1 = cmac_obj.digest()
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(auth_key_2)
|
||||||
|
auth_cmac_key_2 = cmac_obj.digest()
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(auth_key_3)
|
||||||
|
auth_cmac_key_3 = cmac_obj.digest()
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(auth_key_4)
|
||||||
|
auth_cmac_key_4 = cmac_obj.digest()
|
||||||
|
|
||||||
|
auth_cmac_combined_1 = auth_cmac_key_1 + auth_cmac_key_2
|
||||||
|
auth_cmac_combined_2 = auth_cmac_key_3 + auth_cmac_key_4
|
||||||
|
|
||||||
|
session.derived_keys['enc'] = enc_cmac_key
|
||||||
|
session.derived_keys['auth_1'] = auth_cmac_combined_1
|
||||||
|
session.derived_keys['auth_2'] = auth_cmac_combined_2
|
||||||
|
|
||||||
|
self.logger.debug('verifying license signature')
|
||||||
|
|
||||||
|
lic_hmac = HMAC.new(session.derived_keys['auth_1'], digestmod=SHA256)
|
||||||
|
lic_hmac.update(license.Msg.SerializeToString())
|
||||||
|
|
||||||
|
self.logger.debug("calculated sig: {} actual sig: {}".format(lic_hmac.hexdigest(), binascii.hexlify(license.Signature)))
|
||||||
|
|
||||||
|
if lic_hmac.digest() != license.Signature:
|
||||||
|
self.logger.info("license signature doesn't match - writing bin so they can be debugged")
|
||||||
|
with open("original_lic.bin", "wb") as f:
|
||||||
|
f.write(base64.b64decode(license_b64))
|
||||||
|
with open("parsed_lic.bin", "wb") as f:
|
||||||
|
f.write(license.SerializeToString())
|
||||||
|
self.logger.info("continuing anyway")
|
||||||
|
|
||||||
|
self.logger.debug("key count: {}".format(len(license.Msg.Key)))
|
||||||
|
for key in license.Msg.Key:
|
||||||
|
if key.Id:
|
||||||
|
key_id = key.Id
|
||||||
|
else:
|
||||||
|
key_id = wv_proto2.License.KeyContainer.KeyType.Name(key.Type).encode('utf-8')
|
||||||
|
encrypted_key = key.Key
|
||||||
|
iv = key.Iv
|
||||||
|
type = wv_proto2.License.KeyContainer.KeyType.Name(key.Type)
|
||||||
|
|
||||||
|
cipher = AES.new(session.derived_keys['enc'], AES.MODE_CBC, iv=iv)
|
||||||
|
decrypted_key = cipher.decrypt(encrypted_key)
|
||||||
|
if type == "OPERATOR_SESSION":
|
||||||
|
permissions = []
|
||||||
|
perms = key._OperatorSessionKeyPermissions
|
||||||
|
for (descriptor, value) in perms.ListFields():
|
||||||
|
if value == 1:
|
||||||
|
permissions.append(descriptor.name)
|
||||||
|
print(permissions)
|
||||||
|
else:
|
||||||
|
permissions = []
|
||||||
|
session.keys.append(Key(key_id, type, Padding.unpad(decrypted_key, 16), permissions))
|
||||||
|
|
||||||
|
self.logger.info("decrypted all keys")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_keys(self, session_id):
|
||||||
|
if session_id in self.sessions:
|
||||||
|
return self.sessions[session_id].keys
|
||||||
|
else:
|
||||||
|
self.logger.error("session not found")
|
||||||
|
return 1
|
64
pywidevine/cdm/deviceconfig.py
Normal file
64
pywidevine/cdm/deviceconfig.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
device_chromecdm_1610 = {
|
||||||
|
'name': 'chromecdm_1610',
|
||||||
|
'description': 'chrome cdm windows 1610',
|
||||||
|
'security_level': 3,
|
||||||
|
'session_id_type': 'chrome',
|
||||||
|
'private_key_available': True,
|
||||||
|
'vmp': True,
|
||||||
|
'send_key_control_nonce': False
|
||||||
|
}
|
||||||
|
|
||||||
|
device_space_l3 = {
|
||||||
|
'name': 'space_l3',
|
||||||
|
'description': 'Spacecastgfsc100',
|
||||||
|
'security_level': 3,
|
||||||
|
'session_id_type': 'android',
|
||||||
|
'private_key_available': True,
|
||||||
|
'vmp': False,
|
||||||
|
'send_key_control_nonce': True
|
||||||
|
}
|
||||||
|
|
||||||
|
devices_available = [device_chromecdm_1610]
|
||||||
|
|
||||||
|
FILES_FOLDER = 'devices'
|
||||||
|
|
||||||
|
class DeviceConfig:
|
||||||
|
def __init__(self, device):
|
||||||
|
self.device_name = device['name']
|
||||||
|
self.description = device['description']
|
||||||
|
self.security_level = device['security_level']
|
||||||
|
self.session_id_type = device['session_id_type']
|
||||||
|
self.private_key_available = device['private_key_available']
|
||||||
|
self.vmp = device['vmp']
|
||||||
|
self.send_key_control_nonce = device['send_key_control_nonce']
|
||||||
|
|
||||||
|
if 'keybox_filename' in device:
|
||||||
|
self.keybox_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['keybox_filename'])
|
||||||
|
else:
|
||||||
|
self.keybox_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'keybox')
|
||||||
|
|
||||||
|
if 'device_cert_filename' in device:
|
||||||
|
self.device_cert_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['device_cert_filename'])
|
||||||
|
else:
|
||||||
|
self.device_cert_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'device_cert')
|
||||||
|
|
||||||
|
if 'device_private_key_filename' in device:
|
||||||
|
self.device_private_key_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['device_private_key_filename'])
|
||||||
|
else:
|
||||||
|
self.device_private_key_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'device_private_key')
|
||||||
|
|
||||||
|
if 'device_client_id_blob_filename' in device:
|
||||||
|
self.device_client_id_blob_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['device_client_id_blob_filename'])
|
||||||
|
else:
|
||||||
|
self.device_client_id_blob_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'device_client_id_blob')
|
||||||
|
|
||||||
|
if 'device_vmp_blob_filename' in device:
|
||||||
|
self.device_vmp_blob_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['device_vmp_blob_filename'])
|
||||||
|
else:
|
||||||
|
self.device_vmp_blob_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'device_vmp_blob')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "DeviceConfig(name={}, description={}, security_level={}, session_id_type={}, private_key_available={}, vmp={})".format(self.device_name, self.description, self.security_level, self.session_id_type, self.private_key_available, self.vmp)
|
BIN
pywidevine/cdm/devices/chromecdm_1610/device_client_id_blob
Normal file
BIN
pywidevine/cdm/devices/chromecdm_1610/device_client_id_blob
Normal file
Binary file not shown.
27
pywidevine/cdm/devices/chromecdm_1610/device_private_key
Normal file
27
pywidevine/cdm/devices/chromecdm_1610/device_private_key
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEowIBAAKCAQEAmWQki2QefKW8qIs4jUCmWLU+cOJkEt4Kh/5J1cer56hZU0da
|
||||||
|
IDxVVEDkFJ/LoxgZF5bZJP7r2nMhlZco9e8WNF+dSMgIALd9vIwlhXoiNSxOVNMT
|
||||||
|
VSopBO8Tm1TdhRpHuGI14WrEmhDrL26nSC7upAa3GS94Il8JIq/DdF0hierqw9Zo
|
||||||
|
TgKIuYN1iTVmV98J2mRGCSMVE5YnDYUiW0YLyVsdDDHQ2ckIG8GLHiIz7FzAfylO
|
||||||
|
tuNKMC4IxqIoV2J5sTuZEbiE1Vq7ZpaUbXikX+dAkVKnRMuGhPyONURIvSHu6N5P
|
||||||
|
g542lkc6WlKOLnnh9zC+bILOQ8+AwaG9mkAa7QIDAQABAoIBAAlJ+feSANGiFMZt
|
||||||
|
JefR7DDviuA5qX9GAIPDR7PhQ10EQiKLrd7JYI55XoaMNcAC79QQn8ZAhMZmFfjR
|
||||||
|
3lpkV+ckiikT6f4nHn9qSkRmxMcND5JN9PwBkVnZ44lSzpZmpMS699HWjiDZWPC2
|
||||||
|
pf02UF/RA0oMaJ1GPY6i77ZuhF8uaXQmXeBZL1V6inn4+OmS+X2MZ87hT4r49ZF2
|
||||||
|
Ux7MMup6koVtE8iy4LZHYmxP+FwnxUfpbQWnPsWeWjPv/43c2PRKQn9KOSzwwW2v
|
||||||
|
nXIIa6jst7SJefMcrfcs7vFYbML0Wu1nmkNrbguCU5tpidzJTkyM0Vfg8dcpYIFO
|
||||||
|
BM0cquECgYEAzbfkN/b35/J5G8MLMgWel/NPgayrxywwUoKmPPhEe7BJy41xOhsP
|
||||||
|
cmEWcmdchAvce3RFPlcmzDStcTCtJeXHDjhCXv700q1F8JxcS1madv1aRFGQk15M
|
||||||
|
wWPBGIiwRVVBoU/Owt/jGn5f800INAg39tYn78wRZ+LCBkgxxXWOF+ECgYEAvuIP
|
||||||
|
9Kj1Iu+W19idxUZXiH5hvuBFrGqIvYkm8Q3XHyPbT/q49r3HeaCqwtBzoHwTfOhF
|
||||||
|
a7j58/wr7RVKfnIIYPc1xwsfY/ecZ2znX5BWPSqJdiUjdqJSg+z5dun6dGyod2l0
|
||||||
|
dHcdLJP8K1kjPrr+Iq0dAPWaElb528RPDKGLdI0CgYA2YFQvwtUWd04x22VbLIcW
|
||||||
|
LRcn9KdXN0Pym1wro4gelaN77YAvVrXHiwgu50laEfSOoVYoO0sjEQ2DbNVnvgvW
|
||||||
|
o2JPz96QzYI+LmZq+F1O9HrmshSgD34EZETHImJNgVKevASwGBPkjeD447S2ZDG+
|
||||||
|
yi62QN+c8SBOHskhI4iSoQKBgQCrC5tMm1H+mj7K7/qAWgX583XXOtR0KYqafJn+
|
||||||
|
i25nIxRU2NCBmZFztbsOkwfpmQVFekUqwDiKnz8lVVzJbZmAekUgNSFNzQXDz9yM
|
||||||
|
z6PXb5R539GlbtMOEH2CRyv8w5k6V67Y5huoZHskxN1GSv2LSSCiSXJkWLfQbFqB
|
||||||
|
cQiryQKBgDNhLhI/p7mkl7DnRGRstoWWUdUi8u02OchctTpnVPs5v9oYTfLrq/k5
|
||||||
|
K1faq00p/FAgKVRV6KavD6TvhU/JeYUbrQIvIGFCMbKDMy7AUNlWjiFPAkKE7Pdz
|
||||||
|
TO7fnFba0NkZjHd9R1Ljs2Mm3Lzo8rjdFzfFor9YdfGr9qVDkOOp
|
||||||
|
-----END RSA PRIVATE KEY-----
|
BIN
pywidevine/cdm/devices/space_l3/device_client_id_blob
Normal file
BIN
pywidevine/cdm/devices/space_l3/device_client_id_blob
Normal file
Binary file not shown.
27
pywidevine/cdm/devices/space_l3/device_private_key
Normal file
27
pywidevine/cdm/devices/space_l3/device_private_key
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEowIBAAKCAQEAmWQki2QefKW8qIs4jUCmWLU+cOJkEt4Kh/5J1cer56hZU0da
|
||||||
|
IDxVVEDkFJ/LoxgZF5bZJP7r2nMhlZco9e8WNF+dSMgIALd9vIwlhXoiNSxOVNMT
|
||||||
|
VSopBO8Tm1TdhRpHuGI14WrEmhDrL26nSC7upAa3GS94Il8JIq/DdF0hierqw9Zo
|
||||||
|
TgKIuYN1iTVmV98J2mRGCSMVE5YnDYUiW0YLyVsdDDHQ2ckIG8GLHiIz7FzAfylO
|
||||||
|
tuNKMC4IxqIoV2J5sTuZEbiE1Vq7ZpaUbXikX+dAkVKnRMuGhPyONURIvSHu6N5P
|
||||||
|
g542lkc6WlKOLnnh9zC+bILOQ8+AwaG9mkAa7QIDAQABAoIBAAlJ+feSANGiFMZt
|
||||||
|
JefR7DDviuA5qX9GAIPDR7PhQ10EQiKLrd7JYI55XoaMNcAC79QQn8ZAhMZmFfjR
|
||||||
|
3lpkV+ckiikT6f4nHn9qSkRmxMcND5JN9PwBkVnZ44lSzpZmpMS699HWjiDZWPC2
|
||||||
|
pf02UF/RA0oMaJ1GPY6i77ZuhF8uaXQmXeBZL1V6inn4+OmS+X2MZ87hT4r49ZF2
|
||||||
|
Ux7MMup6koVtE8iy4LZHYmxP+FwnxUfpbQWnPsWeWjPv/43c2PRKQn9KOSzwwW2v
|
||||||
|
nXIIa6jst7SJefMcrfcs7vFYbML0Wu1nmkNrbguCU5tpidzJTkyM0Vfg8dcpYIFO
|
||||||
|
BM0cquECgYEAzbfkN/b35/J5G8MLMgWel/NPgayrxywwUoKmPPhEe7BJy41xOhsP
|
||||||
|
cmEWcmdchAvce3RFPlcmzDStcTCtJeXHDjhCXv700q1F8JxcS1madv1aRFGQk15M
|
||||||
|
wWPBGIiwRVVBoU/Owt/jGn5f800INAg39tYn78wRZ+LCBkgxxXWOF+ECgYEAvuIP
|
||||||
|
9Kj1Iu+W19idxUZXiH5hvuBFrGqIvYkm8Q3XHyPbT/q49r3HeaCqwtBzoHwTfOhF
|
||||||
|
a7j58/wr7RVKfnIIYPc1xwsfY/ecZ2znX5BWPSqJdiUjdqJSg+z5dun6dGyod2l0
|
||||||
|
dHcdLJP8K1kjPrr+Iq0dAPWaElb528RPDKGLdI0CgYA2YFQvwtUWd04x22VbLIcW
|
||||||
|
LRcn9KdXN0Pym1wro4gelaN77YAvVrXHiwgu50laEfSOoVYoO0sjEQ2DbNVnvgvW
|
||||||
|
o2JPz96QzYI+LmZq+F1O9HrmshSgD34EZETHImJNgVKevASwGBPkjeD447S2ZDG+
|
||||||
|
yi62QN+c8SBOHskhI4iSoQKBgQCrC5tMm1H+mj7K7/qAWgX583XXOtR0KYqafJn+
|
||||||
|
i25nIxRU2NCBmZFztbsOkwfpmQVFekUqwDiKnz8lVVzJbZmAekUgNSFNzQXDz9yM
|
||||||
|
z6PXb5R539GlbtMOEH2CRyv8w5k6V67Y5huoZHskxN1GSv2LSSCiSXJkWLfQbFqB
|
||||||
|
cQiryQKBgDNhLhI/p7mkl7DnRGRstoWWUdUi8u02OchctTpnVPs5v9oYTfLrq/k5
|
||||||
|
K1faq00p/FAgKVRV6KavD6TvhU/JeYUbrQIvIGFCMbKDMy7AUNlWjiFPAkKE7Pdz
|
||||||
|
TO7fnFba0NkZjHd9R1Ljs2Mm3Lzo8rjdFzfFor9YdfGr9qVDkOOp
|
||||||
|
-----END RSA PRIVATE KEY-----
|
0
pywidevine/cdm/formats/__init__.py
Normal file
0
pywidevine/cdm/formats/__init__.py
Normal file
BIN
pywidevine/cdm/formats/__pycache__/__init__.cpython-36.pyc
Normal file
BIN
pywidevine/cdm/formats/__pycache__/__init__.cpython-36.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/formats/__pycache__/__init__.cpython-37.pyc
Normal file
BIN
pywidevine/cdm/formats/__pycache__/__init__.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/formats/__pycache__/__init__.cpython-38.pyc
Normal file
BIN
pywidevine/cdm/formats/__pycache__/__init__.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/formats/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
pywidevine/cdm/formats/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/formats/__pycache__/wv_proto2_pb2.cpython-36.pyc
Normal file
BIN
pywidevine/cdm/formats/__pycache__/wv_proto2_pb2.cpython-36.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/formats/__pycache__/wv_proto2_pb2.cpython-37.pyc
Normal file
BIN
pywidevine/cdm/formats/__pycache__/wv_proto2_pb2.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/formats/__pycache__/wv_proto2_pb2.cpython-38.pyc
Normal file
BIN
pywidevine/cdm/formats/__pycache__/wv_proto2_pb2.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/formats/__pycache__/wv_proto2_pb2.cpython-39.pyc
Normal file
BIN
pywidevine/cdm/formats/__pycache__/wv_proto2_pb2.cpython-39.pyc
Normal file
Binary file not shown.
466
pywidevine/cdm/formats/wv_proto2.proto
Normal file
466
pywidevine/cdm/formats/wv_proto2.proto
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
// from x86 (partial), most of it from the ARM version:
|
||||||
|
message ClientIdentification {
|
||||||
|
enum TokenType {
|
||||||
|
KEYBOX = 0;
|
||||||
|
DEVICE_CERTIFICATE = 1;
|
||||||
|
REMOTE_ATTESTATION_CERTIFICATE = 2;
|
||||||
|
}
|
||||||
|
message NameValue {
|
||||||
|
required string Name = 1;
|
||||||
|
required string Value = 2;
|
||||||
|
}
|
||||||
|
message ClientCapabilities {
|
||||||
|
enum HdcpVersion {
|
||||||
|
HDCP_NONE = 0;
|
||||||
|
HDCP_V1 = 1;
|
||||||
|
HDCP_V2 = 2;
|
||||||
|
HDCP_V2_1 = 3;
|
||||||
|
HDCP_V2_2 = 4;
|
||||||
|
}
|
||||||
|
optional uint32 ClientToken = 1;
|
||||||
|
optional uint32 SessionToken = 2;
|
||||||
|
optional uint32 VideoResolutionConstraints = 3;
|
||||||
|
optional HdcpVersion MaxHdcpVersion = 4;
|
||||||
|
optional uint32 OemCryptoApiVersion = 5;
|
||||||
|
}
|
||||||
|
required TokenType Type = 1;
|
||||||
|
//optional bytes Token = 2; // by default the client treats this as blob, but it's usually a DeviceCertificate, so for usefulness sake, I'm replacing it with this one:
|
||||||
|
optional SignedDeviceCertificate Token = 2; // use this when parsing, "bytes" when building a client id blob
|
||||||
|
repeated NameValue ClientInfo = 3;
|
||||||
|
optional bytes ProviderClientToken = 4;
|
||||||
|
optional uint32 LicenseCounter = 5;
|
||||||
|
optional ClientCapabilities _ClientCapabilities = 6; // how should we deal with duped names? will have to look at proto docs later
|
||||||
|
optional FileHashes _FileHashes = 7; // vmp blob goes here
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeviceCertificate {
|
||||||
|
enum CertificateType {
|
||||||
|
ROOT = 0;
|
||||||
|
INTERMEDIATE = 1;
|
||||||
|
USER_DEVICE = 2;
|
||||||
|
SERVICE = 3;
|
||||||
|
}
|
||||||
|
required CertificateType Type = 1; // the compiled code reused this as ProvisionedDeviceInfo.WvSecurityLevel, however that is incorrect (compiler aliased it as they're both identical as a structure)
|
||||||
|
optional bytes SerialNumber = 2;
|
||||||
|
optional uint32 CreationTimeSeconds = 3;
|
||||||
|
optional bytes PublicKey = 4;
|
||||||
|
optional uint32 SystemId = 5;
|
||||||
|
optional uint32 TestDeviceDeprecated = 6; // is it bool or int?
|
||||||
|
optional bytes ServiceId = 7; // service URL for service certificates
|
||||||
|
}
|
||||||
|
|
||||||
|
// missing some references,
|
||||||
|
message DeviceCertificateStatus {
|
||||||
|
enum CertificateStatus {
|
||||||
|
VALID = 0;
|
||||||
|
REVOKED = 1;
|
||||||
|
}
|
||||||
|
optional bytes SerialNumber = 1;
|
||||||
|
optional CertificateStatus Status = 2;
|
||||||
|
optional ProvisionedDeviceInfo DeviceInfo = 4; // where is 3? is it deprecated?
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeviceCertificateStatusList {
|
||||||
|
optional uint32 CreationTimeSeconds = 1;
|
||||||
|
repeated DeviceCertificateStatus CertificateStatus = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EncryptedClientIdentification {
|
||||||
|
required string ServiceId = 1;
|
||||||
|
optional bytes ServiceCertificateSerialNumber = 2;
|
||||||
|
required bytes EncryptedClientId = 3;
|
||||||
|
required bytes EncryptedClientIdIv = 4;
|
||||||
|
required bytes EncryptedPrivacyKey = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill (for this top-level type, it might be impossible/difficult)
|
||||||
|
enum LicenseType {
|
||||||
|
ZERO = 0;
|
||||||
|
DEFAULT = 1; // 1 is STREAMING/temporary license; on recent versions may go up to 3 (latest x86); it might be persist/don't persist type, unconfirmed
|
||||||
|
OFFLINE = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill (for this top-level type, it might be impossible/difficult)
|
||||||
|
// this is just a guess because these globals got lost, but really, do we need more?
|
||||||
|
enum ProtocolVersion {
|
||||||
|
CURRENT = 21; // don't have symbols for this
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message LicenseIdentification {
|
||||||
|
optional bytes RequestId = 1;
|
||||||
|
optional bytes SessionId = 2;
|
||||||
|
optional bytes PurchaseId = 3;
|
||||||
|
optional LicenseType Type = 4;
|
||||||
|
optional uint32 Version = 5;
|
||||||
|
optional bytes ProviderSessionToken = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message License {
|
||||||
|
message Policy {
|
||||||
|
optional bool CanPlay = 1; // changed from uint32 to bool
|
||||||
|
optional bool CanPersist = 2;
|
||||||
|
optional bool CanRenew = 3;
|
||||||
|
optional uint32 RentalDurationSeconds = 4;
|
||||||
|
optional uint32 PlaybackDurationSeconds = 5;
|
||||||
|
optional uint32 LicenseDurationSeconds = 6;
|
||||||
|
optional uint32 RenewalRecoveryDurationSeconds = 7;
|
||||||
|
optional string RenewalServerUrl = 8;
|
||||||
|
optional uint32 RenewalDelaySeconds = 9;
|
||||||
|
optional uint32 RenewalRetryIntervalSeconds = 10;
|
||||||
|
optional bool RenewWithUsage = 11; // was uint32
|
||||||
|
}
|
||||||
|
message KeyContainer {
|
||||||
|
enum KeyType {
|
||||||
|
SIGNING = 1;
|
||||||
|
CONTENT = 2;
|
||||||
|
KEY_CONTROL = 3;
|
||||||
|
OPERATOR_SESSION = 4;
|
||||||
|
}
|
||||||
|
enum SecurityLevel {
|
||||||
|
SW_SECURE_CRYPTO = 1;
|
||||||
|
SW_SECURE_DECODE = 2;
|
||||||
|
HW_SECURE_CRYPTO = 3;
|
||||||
|
HW_SECURE_DECODE = 4;
|
||||||
|
HW_SECURE_ALL = 5;
|
||||||
|
}
|
||||||
|
message OutputProtection {
|
||||||
|
enum CGMS {
|
||||||
|
COPY_FREE = 0;
|
||||||
|
COPY_ONCE = 2;
|
||||||
|
COPY_NEVER = 3;
|
||||||
|
CGMS_NONE = 0x2A; // PC default!
|
||||||
|
}
|
||||||
|
optional ClientIdentification.ClientCapabilities.HdcpVersion Hdcp = 1; // it's most likely a copy of Hdcp version available here, but compiler optimized it away
|
||||||
|
optional CGMS CgmsFlags = 2;
|
||||||
|
}
|
||||||
|
message KeyControl {
|
||||||
|
required bytes KeyControlBlock = 1; // what is this?
|
||||||
|
required bytes Iv = 2;
|
||||||
|
}
|
||||||
|
message OperatorSessionKeyPermissions {
|
||||||
|
optional uint32 AllowEncrypt = 1;
|
||||||
|
optional uint32 AllowDecrypt = 2;
|
||||||
|
optional uint32 AllowSign = 3;
|
||||||
|
optional uint32 AllowSignatureVerify = 4;
|
||||||
|
}
|
||||||
|
message VideoResolutionConstraint {
|
||||||
|
optional uint32 MinResolutionPixels = 1;
|
||||||
|
optional uint32 MaxResolutionPixels = 2;
|
||||||
|
optional OutputProtection RequiredProtection = 3;
|
||||||
|
}
|
||||||
|
optional bytes Id = 1;
|
||||||
|
optional bytes Iv = 2;
|
||||||
|
optional bytes Key = 3;
|
||||||
|
optional KeyType Type = 4;
|
||||||
|
optional SecurityLevel Level = 5;
|
||||||
|
optional OutputProtection RequiredProtection = 6;
|
||||||
|
optional OutputProtection RequestedProtection = 7;
|
||||||
|
optional KeyControl _KeyControl = 8; // duped names, etc
|
||||||
|
optional OperatorSessionKeyPermissions _OperatorSessionKeyPermissions = 9; // duped names, etc
|
||||||
|
repeated VideoResolutionConstraint VideoResolutionConstraints = 10;
|
||||||
|
}
|
||||||
|
optional LicenseIdentification Id = 1;
|
||||||
|
optional Policy _Policy = 2; // duped names, etc
|
||||||
|
repeated KeyContainer Key = 3;
|
||||||
|
optional uint32 LicenseStartTime = 4;
|
||||||
|
optional uint32 RemoteAttestationVerified = 5; // bool?
|
||||||
|
optional bytes ProviderClientToken = 6;
|
||||||
|
// there might be more, check with newer versions (I see field 7-8 in a lic)
|
||||||
|
// this appeared in latest x86:
|
||||||
|
optional uint32 ProtectionScheme = 7; // type unconfirmed fully, but it's likely as WidevineCencHeader describesit (fourcc)
|
||||||
|
}
|
||||||
|
|
||||||
|
message LicenseError {
|
||||||
|
enum Error {
|
||||||
|
INVALID_DEVICE_CERTIFICATE = 1;
|
||||||
|
REVOKED_DEVICE_CERTIFICATE = 2;
|
||||||
|
SERVICE_UNAVAILABLE = 3;
|
||||||
|
}
|
||||||
|
//LicenseRequest.RequestType ErrorCode; // clang mismatch
|
||||||
|
optional Error ErrorCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LicenseRequest {
|
||||||
|
message ContentIdentification {
|
||||||
|
message CENC {
|
||||||
|
//optional bytes Pssh = 1; // the client's definition is opaque, it doesn't care about the contents, but the PSSH has a clear definition that is understood and requested by the server, thus I'll replace it with:
|
||||||
|
optional WidevineCencHeader Pssh = 1;
|
||||||
|
optional LicenseType LicenseType = 2; // unfortunately the LicenseType symbols are not present, acceptable value seems to only be 1 (is this persist/don't persist? look into it!)
|
||||||
|
optional bytes RequestId = 3;
|
||||||
|
}
|
||||||
|
message WebM {
|
||||||
|
optional bytes Header = 1; // identical to CENC, aside from PSSH and the parent field number used
|
||||||
|
optional LicenseType LicenseType = 2;
|
||||||
|
optional bytes RequestId = 3;
|
||||||
|
}
|
||||||
|
message ExistingLicense {
|
||||||
|
optional LicenseIdentification LicenseId = 1;
|
||||||
|
optional uint32 SecondsSinceStarted = 2;
|
||||||
|
optional uint32 SecondsSinceLastPlayed = 3;
|
||||||
|
optional bytes SessionUsageTableEntry = 4; // interesting! try to figure out the connection between the usage table blob and KCB!
|
||||||
|
}
|
||||||
|
optional CENC CencId = 1;
|
||||||
|
optional WebM WebmId = 2;
|
||||||
|
optional ExistingLicense License = 3;
|
||||||
|
}
|
||||||
|
enum RequestType {
|
||||||
|
NEW = 1;
|
||||||
|
RENEWAL = 2;
|
||||||
|
RELEASE = 3;
|
||||||
|
}
|
||||||
|
optional ClientIdentification ClientId = 1;
|
||||||
|
optional ContentIdentification ContentId = 2;
|
||||||
|
optional RequestType Type = 3;
|
||||||
|
optional uint32 RequestTime = 4;
|
||||||
|
optional bytes KeyControlNonceDeprecated = 5;
|
||||||
|
optional ProtocolVersion ProtocolVersion = 6; // lacking symbols for this
|
||||||
|
optional uint32 KeyControlNonce = 7;
|
||||||
|
optional EncryptedClientIdentification EncryptedClientId = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw pssh hack
|
||||||
|
message LicenseRequestRaw {
|
||||||
|
message ContentIdentification {
|
||||||
|
message CENC {
|
||||||
|
optional bytes Pssh = 1; // the client's definition is opaque, it doesn't care about the contents, but the PSSH has a clear definition that is understood and requested by the server, thus I'll replace it with:
|
||||||
|
//optional WidevineCencHeader Pssh = 1;
|
||||||
|
optional LicenseType LicenseType = 2; // unfortunately the LicenseType symbols are not present, acceptable value seems to only be 1 (is this persist/don't persist? look into it!)
|
||||||
|
optional bytes RequestId = 3;
|
||||||
|
}
|
||||||
|
message WebM {
|
||||||
|
optional bytes Header = 1; // identical to CENC, aside from PSSH and the parent field number used
|
||||||
|
optional LicenseType LicenseType = 2;
|
||||||
|
optional bytes RequestId = 3;
|
||||||
|
}
|
||||||
|
message ExistingLicense {
|
||||||
|
optional LicenseIdentification LicenseId = 1;
|
||||||
|
optional uint32 SecondsSinceStarted = 2;
|
||||||
|
optional uint32 SecondsSinceLastPlayed = 3;
|
||||||
|
optional bytes SessionUsageTableEntry = 4; // interesting! try to figure out the connection between the usage table blob and KCB!
|
||||||
|
}
|
||||||
|
optional CENC CencId = 1;
|
||||||
|
optional WebM WebmId = 2;
|
||||||
|
optional ExistingLicense License = 3;
|
||||||
|
}
|
||||||
|
enum RequestType {
|
||||||
|
NEW = 1;
|
||||||
|
RENEWAL = 2;
|
||||||
|
RELEASE = 3;
|
||||||
|
}
|
||||||
|
optional ClientIdentification ClientId = 1;
|
||||||
|
optional ContentIdentification ContentId = 2;
|
||||||
|
optional RequestType Type = 3;
|
||||||
|
optional uint32 RequestTime = 4;
|
||||||
|
optional bytes KeyControlNonceDeprecated = 5;
|
||||||
|
optional ProtocolVersion ProtocolVersion = 6; // lacking symbols for this
|
||||||
|
optional uint32 KeyControlNonce = 7;
|
||||||
|
optional EncryptedClientIdentification EncryptedClientId = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message ProvisionedDeviceInfo {
|
||||||
|
enum WvSecurityLevel {
|
||||||
|
LEVEL_UNSPECIFIED = 0;
|
||||||
|
LEVEL_1 = 1;
|
||||||
|
LEVEL_2 = 2;
|
||||||
|
LEVEL_3 = 3;
|
||||||
|
}
|
||||||
|
optional uint32 SystemId = 1;
|
||||||
|
optional string Soc = 2;
|
||||||
|
optional string Manufacturer = 3;
|
||||||
|
optional string Model = 4;
|
||||||
|
optional string DeviceType = 5;
|
||||||
|
optional uint32 ModelYear = 6;
|
||||||
|
optional WvSecurityLevel SecurityLevel = 7;
|
||||||
|
optional uint32 TestDevice = 8; // bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message ProvisioningOptions {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message ProvisioningRequest {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message ProvisioningResponse {
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoteAttestation {
|
||||||
|
optional EncryptedClientIdentification Certificate = 1;
|
||||||
|
optional string Salt = 2;
|
||||||
|
optional string Signature = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SessionInit {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SessionState {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SignedCertificateStatusList {
|
||||||
|
}
|
||||||
|
|
||||||
|
message SignedDeviceCertificate {
|
||||||
|
|
||||||
|
//optional bytes DeviceCertificate = 1; // again, they use a buffer where it's supposed to be a message, so we'll replace it with what it really is:
|
||||||
|
optional DeviceCertificate _DeviceCertificate = 1; // how should we deal with duped names? will have to look at proto docs later
|
||||||
|
optional bytes Signature = 2;
|
||||||
|
optional SignedDeviceCertificate Signer = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SignedProvisioningMessage {
|
||||||
|
}
|
||||||
|
|
||||||
|
// the root of all messages, from either server or client
|
||||||
|
message SignedMessage {
|
||||||
|
enum MessageType {
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
optional bytes Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
optional RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// This message is copied from google's docs, not reversed:
|
||||||
|
message WidevineCencHeader {
|
||||||
|
enum Algorithm {
|
||||||
|
UNENCRYPTED = 0;
|
||||||
|
AESCTR = 1;
|
||||||
|
};
|
||||||
|
optional Algorithm algorithm = 1;
|
||||||
|
repeated bytes key_id = 2;
|
||||||
|
|
||||||
|
// Content provider name.
|
||||||
|
optional string provider = 3;
|
||||||
|
|
||||||
|
// A content identifier, specified by content provider.
|
||||||
|
optional bytes content_id = 4;
|
||||||
|
|
||||||
|
// Track type. Acceptable values are SD, HD and AUDIO. Used to
|
||||||
|
// differentiate content keys used by an asset.
|
||||||
|
optional string track_type_deprecated = 5;
|
||||||
|
|
||||||
|
// The name of a registered policy to be used for this asset.
|
||||||
|
optional string policy = 6;
|
||||||
|
|
||||||
|
// Crypto period index, for media using key rotation.
|
||||||
|
optional uint32 crypto_period_index = 7;
|
||||||
|
|
||||||
|
// Optional protected context for group content. The grouped_license is a
|
||||||
|
// serialized SignedMessage.
|
||||||
|
optional bytes grouped_license = 8;
|
||||||
|
|
||||||
|
// Protection scheme identifying the encryption algorithm.
|
||||||
|
// Represented as one of the following 4CC values:
|
||||||
|
// 'cenc' (AESCTR), 'cbc1' (AESCBC),
|
||||||
|
// 'cens' (AESCTR subsample), 'cbcs' (AESCBC subsample).
|
||||||
|
optional uint32 protection_scheme = 9;
|
||||||
|
|
||||||
|
// Optional. For media using key rotation, this represents the duration
|
||||||
|
// of each crypto period in seconds.
|
||||||
|
optional uint32 crypto_period_seconds = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// remove these when using it outside of protoc:
|
||||||
|
|
||||||
|
// from here on, it's just for testing, these messages don't exist in the binaries, I'm adding them to avoid detecting type programmatically
|
||||||
|
message SignedLicenseRequest {
|
||||||
|
enum MessageType {
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
optional LicenseRequest Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
optional RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// hack
|
||||||
|
message SignedLicenseRequestRaw {
|
||||||
|
enum MessageType {
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
optional LicenseRequestRaw Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
optional RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message SignedLicense {
|
||||||
|
enum MessageType {
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
optional License Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
optional RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SignedServiceCertificate {
|
||||||
|
enum MessageType {
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
optional SignedDeviceCertificate Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
optional RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
//vmp support
|
||||||
|
message FileHashes {
|
||||||
|
message Signature {
|
||||||
|
optional string filename = 1;
|
||||||
|
optional bool test_signing = 2; //0 - release, 1 - testing
|
||||||
|
optional bytes SHA512Hash = 3;
|
||||||
|
optional bool main_exe = 4; //0 for dlls, 1 for exe, this is field 3 in file
|
||||||
|
optional bytes signature = 5;
|
||||||
|
}
|
||||||
|
optional bytes signer = 1;
|
||||||
|
repeated Signature signatures = 2;
|
||||||
|
}
|
3324
pywidevine/cdm/formats/wv_proto2_pb2.py
Normal file
3324
pywidevine/cdm/formats/wv_proto2_pb2.py
Normal file
File diff suppressed because one or more lines are too long
389
pywidevine/cdm/formats/wv_proto3.proto
Normal file
389
pywidevine/cdm/formats/wv_proto3.proto
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
// beware proto3 won't show missing fields it seems, need to change to "proto2" and add "optional" before every field, and remove all the dummy enum members I added:
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
// from x86 (partial), most of it from the ARM version:
|
||||||
|
message ClientIdentification {
|
||||||
|
enum TokenType {
|
||||||
|
KEYBOX = 0;
|
||||||
|
DEVICE_CERTIFICATE = 1;
|
||||||
|
REMOTE_ATTESTATION_CERTIFICATE = 2;
|
||||||
|
}
|
||||||
|
message NameValue {
|
||||||
|
string Name = 1;
|
||||||
|
string Value = 2;
|
||||||
|
}
|
||||||
|
message ClientCapabilities {
|
||||||
|
enum HdcpVersion {
|
||||||
|
HDCP_NONE = 0;
|
||||||
|
HDCP_V1 = 1;
|
||||||
|
HDCP_V2 = 2;
|
||||||
|
HDCP_V2_1 = 3;
|
||||||
|
HDCP_V2_2 = 4;
|
||||||
|
}
|
||||||
|
uint32 ClientToken = 1;
|
||||||
|
uint32 SessionToken = 2;
|
||||||
|
uint32 VideoResolutionConstraints = 3;
|
||||||
|
HdcpVersion MaxHdcpVersion = 4;
|
||||||
|
uint32 OemCryptoApiVersion = 5;
|
||||||
|
}
|
||||||
|
TokenType Type = 1;
|
||||||
|
//bytes Token = 2; // by default the client treats this as blob, but it's usually a DeviceCertificate, so for usefulness sake, I'm replacing it with this one:
|
||||||
|
SignedDeviceCertificate Token = 2;
|
||||||
|
repeated NameValue ClientInfo = 3;
|
||||||
|
bytes ProviderClientToken = 4;
|
||||||
|
uint32 LicenseCounter = 5;
|
||||||
|
ClientCapabilities _ClientCapabilities = 6; // how should we deal with duped names? will have to look at proto docs later
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeviceCertificate {
|
||||||
|
enum CertificateType {
|
||||||
|
ROOT = 0;
|
||||||
|
INTERMEDIATE = 1;
|
||||||
|
USER_DEVICE = 2;
|
||||||
|
SERVICE = 3;
|
||||||
|
}
|
||||||
|
//ProvisionedDeviceInfo.WvSecurityLevel Type = 1; // is this how one is supposed to call it? (it's an enum) there might be a bug here, with CertificateType getting confused with WvSecurityLevel, for now renaming it (verify against other binaries)
|
||||||
|
CertificateType Type = 1;
|
||||||
|
bytes SerialNumber = 2;
|
||||||
|
uint32 CreationTimeSeconds = 3;
|
||||||
|
bytes PublicKey = 4;
|
||||||
|
uint32 SystemId = 5;
|
||||||
|
uint32 TestDeviceDeprecated = 6; // is it bool or int?
|
||||||
|
bytes ServiceId = 7; // service URL for service certificates
|
||||||
|
}
|
||||||
|
|
||||||
|
// missing some references,
|
||||||
|
message DeviceCertificateStatus {
|
||||||
|
enum CertificateStatus {
|
||||||
|
VALID = 0;
|
||||||
|
REVOKED = 1;
|
||||||
|
}
|
||||||
|
bytes SerialNumber = 1;
|
||||||
|
CertificateStatus Status = 2;
|
||||||
|
ProvisionedDeviceInfo DeviceInfo = 4; // where is 3? is it deprecated?
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeviceCertificateStatusList {
|
||||||
|
uint32 CreationTimeSeconds = 1;
|
||||||
|
repeated DeviceCertificateStatus CertificateStatus = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EncryptedClientIdentification {
|
||||||
|
string ServiceId = 1;
|
||||||
|
bytes ServiceCertificateSerialNumber = 2;
|
||||||
|
bytes EncryptedClientId = 3;
|
||||||
|
bytes EncryptedClientIdIv = 4;
|
||||||
|
bytes EncryptedPrivacyKey = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill (for this top-level type, it might be impossible/difficult)
|
||||||
|
enum LicenseType {
|
||||||
|
ZERO = 0;
|
||||||
|
DEFAULT = 1; // do not know what this is either, but should be 1; on recent versions may go up to 3 (latest x86)
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill (for this top-level type, it might be impossible/difficult)
|
||||||
|
// this is just a guess because these globals got lost, but really, do we need more?
|
||||||
|
enum ProtocolVersion {
|
||||||
|
DUMMY = 0;
|
||||||
|
CURRENT = 21; // don't have symbols for this
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message LicenseIdentification {
|
||||||
|
bytes RequestId = 1;
|
||||||
|
bytes SessionId = 2;
|
||||||
|
bytes PurchaseId = 3;
|
||||||
|
LicenseType Type = 4;
|
||||||
|
uint32 Version = 5;
|
||||||
|
bytes ProviderSessionToken = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message License {
|
||||||
|
message Policy {
|
||||||
|
uint32 CanPlay = 1;
|
||||||
|
uint32 CanPersist = 2;
|
||||||
|
uint32 CanRenew = 3;
|
||||||
|
uint32 RentalDurationSeconds = 4;
|
||||||
|
uint32 PlaybackDurationSeconds = 5;
|
||||||
|
uint32 LicenseDurationSeconds = 6;
|
||||||
|
uint32 RenewalRecoveryDurationSeconds = 7;
|
||||||
|
string RenewalServerUrl = 8;
|
||||||
|
uint32 RenewalDelaySeconds = 9;
|
||||||
|
uint32 RenewalRetryIntervalSeconds = 10;
|
||||||
|
uint32 RenewWithUsage = 11;
|
||||||
|
uint32 UnknownPolicy12 = 12;
|
||||||
|
}
|
||||||
|
message KeyContainer {
|
||||||
|
enum KeyType {
|
||||||
|
_NOKEYTYPE = 0; // dummy, added to satisfy proto3, not present in original
|
||||||
|
SIGNING = 1;
|
||||||
|
CONTENT = 2;
|
||||||
|
KEY_CONTROL = 3;
|
||||||
|
OPERATOR_SESSION = 4;
|
||||||
|
}
|
||||||
|
enum SecurityLevel {
|
||||||
|
_NOSECLEVEL = 0; // dummy, added to satisfy proto3, not present in original
|
||||||
|
SW_SECURE_CRYPTO = 1;
|
||||||
|
SW_SECURE_DECODE = 2;
|
||||||
|
HW_SECURE_CRYPTO = 3;
|
||||||
|
HW_SECURE_DECODE = 4;
|
||||||
|
HW_SECURE_ALL = 5;
|
||||||
|
}
|
||||||
|
message OutputProtection {
|
||||||
|
enum CGMS {
|
||||||
|
COPY_FREE = 0;
|
||||||
|
COPY_ONCE = 2;
|
||||||
|
COPY_NEVER = 3;
|
||||||
|
CGMS_NONE = 0x2A; // PC default!
|
||||||
|
}
|
||||||
|
ClientIdentification.ClientCapabilities.HdcpVersion Hdcp = 1; // it's most likely a copy of Hdcp version available here, but compiler optimized it away
|
||||||
|
CGMS CgmsFlags = 2;
|
||||||
|
}
|
||||||
|
message KeyControl {
|
||||||
|
bytes KeyControlBlock = 1; // what is this?
|
||||||
|
bytes Iv = 2;
|
||||||
|
}
|
||||||
|
message OperatorSessionKeyPermissions {
|
||||||
|
uint32 AllowEncrypt = 1;
|
||||||
|
uint32 AllowDecrypt = 2;
|
||||||
|
uint32 AllowSign = 3;
|
||||||
|
uint32 AllowSignatureVerify = 4;
|
||||||
|
}
|
||||||
|
message VideoResolutionConstraint {
|
||||||
|
uint32 MinResolutionPixels = 1;
|
||||||
|
uint32 MaxResolutionPixels = 2;
|
||||||
|
OutputProtection RequiredProtection = 3;
|
||||||
|
}
|
||||||
|
bytes Id = 1;
|
||||||
|
bytes Iv = 2;
|
||||||
|
bytes Key = 3;
|
||||||
|
KeyType Type = 4;
|
||||||
|
SecurityLevel Level = 5;
|
||||||
|
OutputProtection RequiredProtection = 6;
|
||||||
|
OutputProtection RequestedProtection = 7;
|
||||||
|
KeyControl _KeyControl = 8; // duped names, etc
|
||||||
|
OperatorSessionKeyPermissions _OperatorSessionKeyPermissions = 9; // duped names, etc
|
||||||
|
repeated VideoResolutionConstraint VideoResolutionConstraints = 10;
|
||||||
|
}
|
||||||
|
LicenseIdentification Id = 1;
|
||||||
|
Policy _Policy = 2; // duped names, etc
|
||||||
|
repeated KeyContainer Key = 3;
|
||||||
|
uint32 LicenseStartTime = 4;
|
||||||
|
uint32 RemoteAttestationVerified = 5; // bool?
|
||||||
|
bytes ProviderClientToken = 6;
|
||||||
|
// there might be more, check with newer versions (I see field 7-8 in a lic)
|
||||||
|
// this appeared in latest x86:
|
||||||
|
uint32 ProtectionScheme = 7; // type unconfirmed fully, but it's likely as WidevineCencHeader describesit (fourcc)
|
||||||
|
bytes UnknownHdcpDataField = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LicenseError {
|
||||||
|
enum Error {
|
||||||
|
DUMMY_NO_ERROR = 0; // dummy, added to satisfy proto3
|
||||||
|
INVALID_DEVICE_CERTIFICATE = 1;
|
||||||
|
REVOKED_DEVICE_CERTIFICATE = 2;
|
||||||
|
SERVICE_UNAVAILABLE = 3;
|
||||||
|
}
|
||||||
|
//LicenseRequest.RequestType ErrorCode; // clang mismatch
|
||||||
|
Error ErrorCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LicenseRequest {
|
||||||
|
message ContentIdentification {
|
||||||
|
message CENC {
|
||||||
|
// bytes Pssh = 1; // the client's definition is opaque, it doesn't care about the contents, but the PSSH has a clear definition that is understood and requested by the server, thus I'll replace it with:
|
||||||
|
WidevineCencHeader Pssh = 1;
|
||||||
|
LicenseType LicenseType = 2; // unfortunately the LicenseType symbols are not present, acceptable value seems to only be 1
|
||||||
|
bytes RequestId = 3;
|
||||||
|
}
|
||||||
|
message WebM {
|
||||||
|
bytes Header = 1; // identical to CENC, aside from PSSH and the parent field number used
|
||||||
|
LicenseType LicenseType = 2;
|
||||||
|
bytes RequestId = 3;
|
||||||
|
}
|
||||||
|
message ExistingLicense {
|
||||||
|
LicenseIdentification LicenseId = 1;
|
||||||
|
uint32 SecondsSinceStarted = 2;
|
||||||
|
uint32 SecondsSinceLastPlayed = 3;
|
||||||
|
bytes SessionUsageTableEntry = 4;
|
||||||
|
}
|
||||||
|
CENC CencId = 1;
|
||||||
|
WebM WebmId = 2;
|
||||||
|
ExistingLicense License = 3;
|
||||||
|
}
|
||||||
|
enum RequestType {
|
||||||
|
DUMMY_REQ_TYPE = 0; // dummy, added to satisfy proto3
|
||||||
|
NEW = 1;
|
||||||
|
RENEWAL = 2;
|
||||||
|
RELEASE = 3;
|
||||||
|
}
|
||||||
|
ClientIdentification ClientId = 1;
|
||||||
|
ContentIdentification ContentId = 2;
|
||||||
|
RequestType Type = 3;
|
||||||
|
uint32 RequestTime = 4;
|
||||||
|
bytes KeyControlNonceDeprecated = 5;
|
||||||
|
ProtocolVersion ProtocolVersion = 6; // lacking symbols for this
|
||||||
|
uint32 KeyControlNonce = 7;
|
||||||
|
EncryptedClientIdentification EncryptedClientId = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ProvisionedDeviceInfo {
|
||||||
|
enum WvSecurityLevel {
|
||||||
|
LEVEL_UNSPECIFIED = 0;
|
||||||
|
LEVEL_1 = 1;
|
||||||
|
LEVEL_2 = 2;
|
||||||
|
LEVEL_3 = 3;
|
||||||
|
}
|
||||||
|
uint32 SystemId = 1;
|
||||||
|
string Soc = 2;
|
||||||
|
string Manufacturer = 3;
|
||||||
|
string Model = 4;
|
||||||
|
string DeviceType = 5;
|
||||||
|
uint32 ModelYear = 6;
|
||||||
|
WvSecurityLevel SecurityLevel = 7;
|
||||||
|
uint32 TestDevice = 8; // bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message ProvisioningOptions {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message ProvisioningRequest {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message ProvisioningResponse {
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoteAttestation {
|
||||||
|
EncryptedClientIdentification Certificate = 1;
|
||||||
|
string Salt = 2;
|
||||||
|
string Signature = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SessionInit {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SessionState {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SignedCertificateStatusList {
|
||||||
|
}
|
||||||
|
|
||||||
|
message SignedDeviceCertificate {
|
||||||
|
|
||||||
|
//bytes DeviceCertificate = 1; // again, they use a buffer where it's supposed to be a message, so we'll replace it with what it really is:
|
||||||
|
DeviceCertificate _DeviceCertificate = 1; // how should we deal with duped names? will have to look at proto docs later
|
||||||
|
bytes Signature = 2;
|
||||||
|
SignedDeviceCertificate Signer = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SignedProvisioningMessage {
|
||||||
|
}
|
||||||
|
|
||||||
|
// the root of all messages, from either server or client
|
||||||
|
message SignedMessage {
|
||||||
|
enum MessageType {
|
||||||
|
DUMMY_MSG_TYPE = 0; // dummy, added to satisfy proto3
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
bytes Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// This message is copied from google's docs, not reversed:
|
||||||
|
message WidevineCencHeader {
|
||||||
|
enum Algorithm {
|
||||||
|
UNENCRYPTED = 0;
|
||||||
|
AESCTR = 1;
|
||||||
|
};
|
||||||
|
Algorithm algorithm = 1;
|
||||||
|
repeated bytes key_id = 2;
|
||||||
|
|
||||||
|
// Content provider name.
|
||||||
|
string provider = 3;
|
||||||
|
|
||||||
|
// A content identifier, specified by content provider.
|
||||||
|
bytes content_id = 4;
|
||||||
|
|
||||||
|
// Track type. Acceptable values are SD, HD and AUDIO. Used to
|
||||||
|
// differentiate content keys used by an asset.
|
||||||
|
string track_type_deprecated = 5;
|
||||||
|
|
||||||
|
// The name of a registered policy to be used for this asset.
|
||||||
|
string policy = 6;
|
||||||
|
|
||||||
|
// Crypto period index, for media using key rotation.
|
||||||
|
uint32 crypto_period_index = 7;
|
||||||
|
|
||||||
|
// Optional protected context for group content. The grouped_license is a
|
||||||
|
// serialized SignedMessage.
|
||||||
|
bytes grouped_license = 8;
|
||||||
|
|
||||||
|
// Protection scheme identifying the encryption algorithm.
|
||||||
|
// Represented as one of the following 4CC values:
|
||||||
|
// 'cenc' (AESCTR), 'cbc1' (AESCBC),
|
||||||
|
// 'cens' (AESCTR subsample), 'cbcs' (AESCBC subsample).
|
||||||
|
uint32 protection_scheme = 9;
|
||||||
|
|
||||||
|
// Optional. For media using key rotation, this represents the duration
|
||||||
|
// of each crypto period in seconds.
|
||||||
|
uint32 crypto_period_seconds = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// from here on, it's just for testing, these messages don't exist in the binaries, I'm adding them to avoid detecting type programmatically
|
||||||
|
message SignedLicenseRequest {
|
||||||
|
enum MessageType {
|
||||||
|
DUMMY_MSG_TYPE = 0; // dummy, added to satisfy proto3
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
LicenseRequest Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SignedLicense {
|
||||||
|
enum MessageType {
|
||||||
|
DUMMY_MSG_TYPE = 0; // dummy, added to satisfy proto3
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
License Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
2686
pywidevine/cdm/formats/wv_proto3_pb2.py
Normal file
2686
pywidevine/cdm/formats/wv_proto3_pb2.py
Normal file
File diff suppressed because one or more lines are too long
14
pywidevine/cdm/key.py
Normal file
14
pywidevine/cdm/key.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import binascii
|
||||||
|
|
||||||
|
class Key:
|
||||||
|
def __init__(self, kid, type, key, permissions=[]):
|
||||||
|
self.kid = kid
|
||||||
|
self.type = type
|
||||||
|
self.key = key
|
||||||
|
self.permissions = permissions
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
if self.type == "OPERATOR_SESSION":
|
||||||
|
return "key(kid={}, type={}, key={}, permissions={})".format(self.kid, self.type, binascii.hexlify(self.key), self.permissions)
|
||||||
|
else:
|
||||||
|
return "key(kid={}, type={}, key={})".format(self.kid, self.type, binascii.hexlify(self.key))
|
18
pywidevine/cdm/session.py
Normal file
18
pywidevine/cdm/session.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
class Session:
|
||||||
|
def __init__(self, session_id, init_data, device_config, offline):
|
||||||
|
self.session_id = session_id
|
||||||
|
self.init_data = init_data
|
||||||
|
self.offline = offline
|
||||||
|
self.device_config = device_config
|
||||||
|
self.device_key = None
|
||||||
|
self.session_key = None
|
||||||
|
self.derived_keys = {
|
||||||
|
'enc': None,
|
||||||
|
'auth_1': None,
|
||||||
|
'auth_2': None
|
||||||
|
}
|
||||||
|
self.license_request = None
|
||||||
|
self.license = None
|
||||||
|
self.service_certificate = None
|
||||||
|
self.privacy_mode = False
|
||||||
|
self.keys = []
|
102
pywidevine/cdm/vmp.py
Normal file
102
pywidevine/cdm/vmp.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
try:
|
||||||
|
from google.protobuf.internal.decoder import _DecodeVarint as _di # this was tested to work with protobuf 3, but it's an internal API (any varint decoder might work)
|
||||||
|
except ImportError:
|
||||||
|
# this is generic and does not depend on pb internals, however it will decode "larger" possible numbers than pb decoder which has them fixed
|
||||||
|
def LEB128_decode(buffer, pos, limit = 64):
|
||||||
|
result = 0
|
||||||
|
shift = 0
|
||||||
|
while True:
|
||||||
|
b = buffer[pos]
|
||||||
|
pos += 1
|
||||||
|
result |= ((b & 0x7F) << shift)
|
||||||
|
if not (b & 0x80):
|
||||||
|
return (result, pos)
|
||||||
|
shift += 7
|
||||||
|
if shift > limit:
|
||||||
|
raise Exception("integer too large, shift: {}".format(shift))
|
||||||
|
_di = LEB128_decode
|
||||||
|
|
||||||
|
|
||||||
|
class FromFileMixin:
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, filename):
|
||||||
|
"""Load given a filename"""
|
||||||
|
with open(filename,"rb") as f:
|
||||||
|
return cls(f.read())
|
||||||
|
|
||||||
|
# the signatures use a format internally similar to
|
||||||
|
# protobuf's encoding, but without wire types
|
||||||
|
class VariableReader(FromFileMixin):
|
||||||
|
"""Protobuf-like encoding reader"""
|
||||||
|
|
||||||
|
def __init__(self, buf):
|
||||||
|
self.buf = buf
|
||||||
|
self.pos = 0
|
||||||
|
self.size = len(buf)
|
||||||
|
|
||||||
|
def read_int(self):
|
||||||
|
"""Read a variable length integer"""
|
||||||
|
# _DecodeVarint will take care of out of range errors
|
||||||
|
(val, nextpos) = _di(self.buf, self.pos)
|
||||||
|
self.pos = nextpos
|
||||||
|
return val
|
||||||
|
|
||||||
|
def read_bytes_raw(self, size):
|
||||||
|
"""Read size bytes"""
|
||||||
|
b = self.buf[self.pos:self.pos+size]
|
||||||
|
self.pos += size
|
||||||
|
return b
|
||||||
|
|
||||||
|
def read_bytes(self):
|
||||||
|
"""Read a bytes object"""
|
||||||
|
size = self.read_int()
|
||||||
|
return self.read_bytes_raw(size)
|
||||||
|
|
||||||
|
def is_end(self):
|
||||||
|
return (self.size == self.pos)
|
||||||
|
|
||||||
|
|
||||||
|
class TaggedReader(VariableReader):
|
||||||
|
"""Tagged reader, needed for implementing a WideVine signature reader"""
|
||||||
|
|
||||||
|
def read_tag(self):
|
||||||
|
"""Read a tagged buffer"""
|
||||||
|
return (self.read_int(), self.read_bytes())
|
||||||
|
|
||||||
|
def read_all_tags(self, max_tag=3):
|
||||||
|
tags = {}
|
||||||
|
while (not self.is_end()):
|
||||||
|
(tag, bytes) = self.read_tag()
|
||||||
|
if (tag > max_tag):
|
||||||
|
raise IndexError("tag out of bound: got {}, max {}".format(tag, max_tag))
|
||||||
|
|
||||||
|
tags[tag] = bytes
|
||||||
|
return tags
|
||||||
|
|
||||||
|
class WideVineSignatureReader(FromFileMixin):
|
||||||
|
"""Parses a widevine .sig signature file."""
|
||||||
|
|
||||||
|
SIGNER_TAG = 1
|
||||||
|
SIGNATURE_TAG = 2
|
||||||
|
ISMAINEXE_TAG = 3
|
||||||
|
|
||||||
|
def __init__(self, buf):
|
||||||
|
reader = TaggedReader(buf)
|
||||||
|
self.version = reader.read_int()
|
||||||
|
if (self.version != 0):
|
||||||
|
raise Exception("Unsupported signature format version {}".format(self.version))
|
||||||
|
self.tags = reader.read_all_tags()
|
||||||
|
|
||||||
|
self.signer = self.tags[self.SIGNER_TAG]
|
||||||
|
self.signature = self.tags[self.SIGNATURE_TAG]
|
||||||
|
|
||||||
|
extra = self.tags[self.ISMAINEXE_TAG]
|
||||||
|
if (len(extra) != 1 or (extra[0] > 1)):
|
||||||
|
raise Exception("Unexpected 'ismainexe' field value (not '\\x00' or '\\x01'), please check: {0}".format(extra))
|
||||||
|
|
||||||
|
self.mainexe = bool(extra[0])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_tags(cls, filename):
|
||||||
|
"""Return a dictionary of each tag in the signature file"""
|
||||||
|
return cls.from_file(filename).tags
|
0
pywidevine/decrypt/__init__.py
Normal file
0
pywidevine/decrypt/__init__.py
Normal file
BIN
pywidevine/decrypt/__pycache__/__init__.cpython-36.pyc
Normal file
BIN
pywidevine/decrypt/__pycache__/__init__.cpython-36.pyc
Normal file
Binary file not shown.
BIN
pywidevine/decrypt/__pycache__/__init__.cpython-37.pyc
Normal file
BIN
pywidevine/decrypt/__pycache__/__init__.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pywidevine/decrypt/__pycache__/__init__.cpython-38.pyc
Normal file
BIN
pywidevine/decrypt/__pycache__/__init__.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pywidevine/decrypt/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
pywidevine/decrypt/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
pywidevine/decrypt/__pycache__/wvdecrypt.cpython-36.pyc
Normal file
BIN
pywidevine/decrypt/__pycache__/wvdecrypt.cpython-36.pyc
Normal file
Binary file not shown.
BIN
pywidevine/decrypt/__pycache__/wvdecrypt.cpython-37.pyc
Normal file
BIN
pywidevine/decrypt/__pycache__/wvdecrypt.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pywidevine/decrypt/__pycache__/wvdecrypt.cpython-38.pyc
Normal file
BIN
pywidevine/decrypt/__pycache__/wvdecrypt.cpython-38.pyc
Normal file
Binary file not shown.
BIN
pywidevine/decrypt/__pycache__/wvdecrypt.cpython-39.pyc
Normal file
BIN
pywidevine/decrypt/__pycache__/wvdecrypt.cpython-39.pyc
Normal file
Binary file not shown.
BIN
pywidevine/decrypt/__pycache__/wvdecryptconfig.cpython-36.pyc
Normal file
BIN
pywidevine/decrypt/__pycache__/wvdecryptconfig.cpython-36.pyc
Normal file
Binary file not shown.
BIN
pywidevine/decrypt/__pycache__/wvdecryptconfig.cpython-37.pyc
Normal file
BIN
pywidevine/decrypt/__pycache__/wvdecryptconfig.cpython-37.pyc
Normal file
Binary file not shown.
46
pywidevine/decrypt/wvdecrypt.py
Normal file
46
pywidevine/decrypt/wvdecrypt.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
from tqdm import tqdm
|
||||||
|
import base64
|
||||||
|
from pywidevine.cdm import cdm, deviceconfig
|
||||||
|
|
||||||
|
class WvDecrypt(object):
|
||||||
|
|
||||||
|
WV_SYSTEM_ID = [237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33, 237]
|
||||||
|
|
||||||
|
def __init__(self, PSSH):
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.wvdecrypt_process = None
|
||||||
|
self.pssh = PSSH
|
||||||
|
self.cdm = cdm.Cdm()
|
||||||
|
|
||||||
|
def check_pssh(pssh_b64):
|
||||||
|
pssh = base64.b64decode(pssh_b64)
|
||||||
|
if not pssh[12:28] == bytes(self.WV_SYSTEM_ID):
|
||||||
|
new_pssh = bytearray([0,0,0])
|
||||||
|
new_pssh.append(32+len(pssh))
|
||||||
|
new_pssh[4:] = bytearray(b'pssh')
|
||||||
|
new_pssh[8:] = [0,0,0,0]
|
||||||
|
new_pssh[13:] = self.WV_SYSTEM_ID
|
||||||
|
new_pssh[29:] = [0,0,0,0]
|
||||||
|
new_pssh[31] = len(pssh)
|
||||||
|
new_pssh[32:] = pssh
|
||||||
|
return base64.b64encode(new_pssh)
|
||||||
|
else:
|
||||||
|
return pssh_b64
|
||||||
|
|
||||||
|
self.session = self.cdm.open_session(check_pssh(self.pssh),
|
||||||
|
deviceconfig.DeviceConfig(deviceconfig.device_chromecdm_1610))
|
||||||
|
|
||||||
|
def start_process(self):
|
||||||
|
keysR = self.cdm.get_keys(self.session)
|
||||||
|
return keysR
|
||||||
|
|
||||||
|
def get_challenge(self):
|
||||||
|
return self.cdm.get_license_request(self.session)
|
||||||
|
|
||||||
|
def update_license(self, license_b64):
|
||||||
|
self.cdm.provide_license(self.session, license_b64)
|
||||||
|
return True
|
||||||
|
|
57
pywidevine/decrypt/wvdecryptconfig.py
Normal file
57
pywidevine/decrypt/wvdecryptconfig.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import pywidevine.downloader.wvdownloaderconfig as wvdl_cfg
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
class WvDecryptConfig(object):
|
||||||
|
def __init__(self, filename, tracktype, trackno, license, init_data_b64, cert_data_b64=None):
|
||||||
|
self.filename = filename
|
||||||
|
self.tracktype = tracktype
|
||||||
|
self.trackno = trackno
|
||||||
|
self.init_data_b64 = init_data_b64
|
||||||
|
self.license = license
|
||||||
|
if cert_data_b64 is not None:
|
||||||
|
self.server_cert_required = True
|
||||||
|
self.cert_data_b64 = cert_data_b64
|
||||||
|
else:
|
||||||
|
self.server_cert_required = False
|
||||||
|
|
||||||
|
def get_filename(self, unformatted_filename):
|
||||||
|
return unformatted_filename.format(filename=self.filename, track_type=self.tracktype, track_no=self.trackno)
|
||||||
|
|
||||||
|
|
||||||
|
def find_str(self, s, char):
|
||||||
|
index = 0
|
||||||
|
if char in s:
|
||||||
|
c = char[0]
|
||||||
|
for ch in s:
|
||||||
|
if ch == c and s[index:index + len(char)] == char:
|
||||||
|
return index
|
||||||
|
index += 1
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def get_kid(self, filename):
|
||||||
|
mp4dump = subprocess.Popen([wvdl_cfg.MP4DUMP_BINARY_PATH, filename], stdout=subprocess.PIPE)
|
||||||
|
mp4dump = str(mp4dump.stdout.read())
|
||||||
|
A = self.find_str(mp4dump, 'default_KID')
|
||||||
|
KID = mp4dump[A:A + 63].replace('default_KID = ', '').replace('[', '').replace(']', '').replace(' ', '')
|
||||||
|
KID = KID.upper()
|
||||||
|
KID_video = KID[0:8] + '-' + KID[8:12] + '-' + KID[12:16] + '-' + KID[16:20] + '-' + KID[20:32]
|
||||||
|
if KID == '':
|
||||||
|
KID = 'nothing'
|
||||||
|
return KID.lower()
|
||||||
|
|
||||||
|
def build_commandline_list(self, keys):
|
||||||
|
|
||||||
|
KID_file = self.get_kid(self.get_filename(wvdl_cfg.ENCRYPTED_FILENAME))
|
||||||
|
print(KID_file)
|
||||||
|
|
||||||
|
commandline = [wvdl_cfg.MP4DECRYPT_BINARY_PATH]
|
||||||
|
for key in keys:
|
||||||
|
if key.type == 'CONTENT' and key.kid.hex() == KID_file:
|
||||||
|
print("OK")
|
||||||
|
commandline.append('--key')
|
||||||
|
#key.kid.hex()
|
||||||
|
#2 main high 1 hdr dv hevc
|
||||||
|
commandline.append('{}:{}'.format('2', key.key.hex()))
|
||||||
|
commandline.append(self.get_filename(wvdl_cfg.ENCRYPTED_FILENAME))
|
||||||
|
commandline.append(self.get_filename(wvdl_cfg.DECRYPTED_FILENAME))
|
||||||
|
return commandline
|
Reference in New Issue
Block a user