mirror of
https://github.com/DevLARLEY/WidevineProxy2.git
synced 2026-10-31 07:51:50 +01:00
Add files via upload
This commit is contained in:
@@ -1,2 +1,67 @@
|
||||
# WidevineProxy2
|
||||
An extension-based proxy for Widevine EME challenges and license messages. Modifies the challenge before it reaches the web player and obtains the decryption keys from the response.
|
||||
# WidevineProxy2
|
||||
An extension-based proxy for Widevine EME challenges and license messages. \
|
||||
Modifies the challenge before it reaches the web player and obtains the decryption keys from the response.
|
||||
|
||||
## Features
|
||||
+ User-friendly / GUI-based
|
||||
+ Bypasses one-time tokens, hashes and license wrapping
|
||||
+ JavaScript native Widevine implementation
|
||||
+ Supports Widevine Device files
|
||||
+ Manifest V3 compliant
|
||||
|
||||
## Widevine Devices
|
||||
This addon requires a Widevine Device file to work, which is not provided by this project.
|
||||
+ Follow [this](https://forum.videohelp.com/threads/408031) guide if you want to dump your own device.
|
||||
+ Ready-to-use Widevine Devices can be found on the [VideoHelp forum](https://forum.videohelp.com/forums/48).
|
||||
|
||||
## Compatibility
|
||||
+ Compatible (tested) browsers: Firefox/Chrome on Windows/Linux.
|
||||
+ Works with any service that accepts challenges from Android devices on the same endpoint.
|
||||
|
||||
## Installation
|
||||
+ Chrome
|
||||
1. Download this repository as a ZIP file
|
||||
2. Navigate to `chrome://extensions/`
|
||||
3. Enable `Developer mode`
|
||||
4. Click `Load unpacked` and the select downloaded file
|
||||
+ Firefox
|
||||
+ Persistent installation
|
||||
1. Download the XPI file from the [releases section](https://github.com/DevLARLEY/WidevineProxy2/releases)
|
||||
2. Navigate to `about:addons`
|
||||
3. Click the settings icon and choose `Install Add-on From File...`
|
||||
4. Select the downloaded file
|
||||
+ Temporary installation
|
||||
1. Download this repository as a ZIP file
|
||||
2. Navigate to `about:debugging#/runtime/this-firefox`
|
||||
3. Click `Load Temporary Add-on...` and select the downloaded file
|
||||
|
||||
## Setup
|
||||
+ Once installed, open the extension, click `Choose File` and select your Widevine Device file.
|
||||
+ The files are saved in the extension's `chrome.storage.sync` storage and will be synchronized across any browsers into which the user is signed in with their Google account.
|
||||
+ Due to the sync storage limit of 100KB, the maximum amount of installable of devices at the same time is ~30.
|
||||
+ Check `Enabled` to activate the message interception and you're done.
|
||||
|
||||
## Usage
|
||||
All the user has to do is to play a DRM protected video and the decryption keys should appear in the `Keys` group box (if the service is not unsupported, as stated above). \
|
||||
Keys are saved:
|
||||
+ Temporarily until the extension is either refreshed manually (if installed temporarily) or a removal of the keys is manually initiated.
|
||||
+ Permanently in the extension's `chrome.storage.local` storage until manually wiped or exported via the command line.
|
||||
|
||||
## Issues
|
||||
+ DRM playback won't work when the extension is disabled and EME Logger is active. This caused by my fix for dealing with EME Logger interference (solutions are welcome).
|
||||
|
||||
## Demo
|
||||
[Demo](demo.mp4)
|
||||
|
||||
## Disclaimer
|
||||
+ This program is intended solely for educational purposes.
|
||||
+ Do not use this program to decrypt or access any content for which you do not have the legal rights or explicit permission.
|
||||
+ Unauthorized decryption or distribution of copyrighted materials is a violation of applicable laws and intellectual property rights.
|
||||
+ This tool must not be used for any illegal activities, including but not limited to piracy, circumventing digital rights management (DRM), or unauthorized access to protected content.
|
||||
+ The developers, contributors, and maintainers of this program are not responsible for any misuse or illegal activities performed using this software.
|
||||
+ By using this program, you agree to comply with all applicable laws and regulations governing digital rights and copyright protections.
|
||||
|
||||
## Credits
|
||||
+ [node-widevine](https://github.com/Frooastside/node-widevine)
|
||||
+ [forge](https://github.com/digitalbazaar/forge)
|
||||
+ [protobuf.js](https://github.com/protobufjs/protobuf.js)
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import "./protobuf.min.js";
|
||||
import "./license_protocol.js";
|
||||
import "./forge.min.js";
|
||||
|
||||
import { Session } from "./license.js";
|
||||
import { DeviceManager, base64toUint8Array, uint8ArrayToBase64, uint8ArrayToHex, SettingsManager, AsyncLocalStorage } from "./util.js";
|
||||
import { WidevineDevice } from "./device.js";
|
||||
|
||||
const { LicenseType, SignedMessage, LicenseRequest, License } = protobuf.roots.default.license_protocol;
|
||||
|
||||
let sessions = new Map();
|
||||
let logs = [];
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
(async () => {
|
||||
switch (message.type) {
|
||||
case "REQUEST":
|
||||
if (!await SettingsManager.getEnabled()) {
|
||||
sendResponse(message.body);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JSON.parse(atob(message.body));
|
||||
sendResponse(message.body);
|
||||
return;
|
||||
} catch {
|
||||
if (message.body) {
|
||||
const signed_message = SignedMessage.decode(base64toUint8Array(message.body));
|
||||
const license_request = LicenseRequest.decode(signed_message.msg);
|
||||
const pssh_data = license_request.contentId.widevinePsshData.psshData[0];
|
||||
|
||||
if (!pssh_data) {
|
||||
sendResponse(message.body); // TODO: send report message back or just log from background script
|
||||
return;
|
||||
}
|
||||
|
||||
if (logs.filter(log => log.pssh_data === uint8ArrayToBase64(pssh_data)).length > 0) {
|
||||
console.log("[WidevineProxy2]", `KEYS_ALREADY_RETRIEVED: ${uint8ArrayToBase64(pssh_data)}`);
|
||||
sendResponse(message.body);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected_device_name = await DeviceManager.getSelectedWidevineDevice();
|
||||
if (!selected_device_name) {
|
||||
sendResponse(message.body);
|
||||
return;
|
||||
}
|
||||
|
||||
const device_b64 = await DeviceManager.loadWidevineDevice(selected_device_name);
|
||||
const widevine_device = new WidevineDevice(base64toUint8Array(device_b64).buffer);
|
||||
|
||||
const private_key = `-----BEGIN RSA PRIVATE KEY-----${uint8ArrayToBase64(widevine_device.private_key)}-----END RSA PRIVATE KEY-----`;
|
||||
const session = new Session(
|
||||
{
|
||||
privateKey: private_key,
|
||||
identifierBlob: widevine_device.client_id_bytes
|
||||
},
|
||||
pssh_data
|
||||
);
|
||||
|
||||
const [challenge, request_id] = session.createLicenseRequest(LicenseType.STREAMING, widevine_device.type === 2);
|
||||
sessions.set(uint8ArrayToBase64(request_id), session);
|
||||
|
||||
sendResponse(uint8ArrayToBase64(challenge));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "RESPONSE":
|
||||
if (!await SettingsManager.getEnabled()) {
|
||||
sendResponse(message.body);
|
||||
return;
|
||||
}
|
||||
const tab_url = sender.tab ? sender.tab.url : null;
|
||||
|
||||
try {
|
||||
const clearkey = JSON.parse(atob(message.body));
|
||||
|
||||
const formatted_keys = clearkey["keys"].map(key => ({
|
||||
...key,
|
||||
kid: uint8ArrayToHex(base64toUint8Array(key.kid.replace(/-/g, "+").replace(/_/g, "/") + "==")),
|
||||
k: uint8ArrayToHex(base64toUint8Array(key.k.replace(/-/g, "+").replace(/_/g, "/") + "=="))
|
||||
}));
|
||||
const pssh_data = btoa(JSON.stringify({kids: clearkey["keys"].map(key => key.k)}));
|
||||
|
||||
if (logs.filter(log => log.pssh_data === pssh_data).length > 0) {
|
||||
console.log("[WidevineProxy2]", `KEYS_ALREADY_RETRIEVED: ${pssh_data}`);
|
||||
sendResponse();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("keys", formatted_keys);
|
||||
const log = {
|
||||
type: "CLEARKEY",
|
||||
pssh_data: pssh_data,
|
||||
keys: formatted_keys,
|
||||
url: tab_url,
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
logs.push(log);
|
||||
|
||||
await AsyncLocalStorage.setStorage({[pssh_data]: log});
|
||||
sendResponse();
|
||||
return;
|
||||
} catch (e) {
|
||||
const license = base64toUint8Array(message.body);
|
||||
const signed_license_message = SignedMessage.decode(license);
|
||||
if (signed_license_message.type !== SignedMessage.MessageType.LICENSE) {
|
||||
console.log("[WidevineProxy2]", "INVALID_MESSAGE_TYPE", signed_license_message.type.toString())
|
||||
sendResponse();
|
||||
return;
|
||||
}
|
||||
|
||||
const license_obj = License.decode(signed_license_message.msg);
|
||||
const loaded_request_id = uint8ArrayToBase64(license_obj.id.requestId);
|
||||
|
||||
if (!sessions.has(loaded_request_id)) {
|
||||
sendResponse();
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedSession = sessions.get(loaded_request_id);
|
||||
const keys = await loadedSession.parseLicense(license);
|
||||
const pssh = loadedSession.getPSSH();
|
||||
|
||||
console.log("[WidevineProxy2]", "KEYS", JSON.stringify(keys), tab_url);
|
||||
const log = {
|
||||
type: "WIDEVINE",
|
||||
pssh_data: pssh,
|
||||
keys: keys,
|
||||
url: tab_url,
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
logs.push(log);
|
||||
await AsyncLocalStorage.setStorage({[pssh]: log});
|
||||
|
||||
sessions.delete(loaded_request_id);
|
||||
sendResponse();
|
||||
return;
|
||||
}
|
||||
case "GET_LOGS":
|
||||
sendResponse(logs);
|
||||
break;
|
||||
case "OPEN_PICKER":
|
||||
chrome.windows.create({
|
||||
url: 'picker/filePicker.html',
|
||||
type: 'popup',
|
||||
width: 300,
|
||||
height: 200,
|
||||
});
|
||||
break;
|
||||
case "CLEAR":
|
||||
logs = [];
|
||||
break;
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
export class AES_CMAC {
|
||||
BLOCK_SIZE = 16;
|
||||
XOR_RIGHT = new Uint8Array([
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x87
|
||||
]);
|
||||
EMPTY_BLOCK_SIZE_BUFFER = new Uint8Array(this.BLOCK_SIZE);
|
||||
|
||||
constructor(key) {
|
||||
if (![16, 24, 32].includes(key.length)) {
|
||||
throw new Error("Key size must be 128, 192, or 256 bits.");
|
||||
}
|
||||
this._key = key;
|
||||
}
|
||||
|
||||
async calculate(message) {
|
||||
this._subkeys = await this._generateSubkeys();
|
||||
const blockCount = this._getBlockCount(message);
|
||||
|
||||
let x = this.EMPTY_BLOCK_SIZE_BUFFER;
|
||||
let y;
|
||||
|
||||
for (let i = 0; i < blockCount - 1; i++) {
|
||||
const from = i * this.BLOCK_SIZE;
|
||||
const block = message.subarray(from, from + this.BLOCK_SIZE);
|
||||
y = this._xor(x, block);
|
||||
x = await this._aes(y);
|
||||
}
|
||||
|
||||
y = this._xor(x, this._getLastBlock(message));
|
||||
x = await this._aes(y);
|
||||
|
||||
return x;
|
||||
}
|
||||
|
||||
async _generateSubkeys() {
|
||||
const l = await this._aes(this.EMPTY_BLOCK_SIZE_BUFFER);
|
||||
|
||||
let first = this._bitShiftLeft(l);
|
||||
if (l[0] & 0x80) {
|
||||
first = this._xor(first, this.XOR_RIGHT);
|
||||
}
|
||||
|
||||
let second = this._bitShiftLeft(first);
|
||||
if (first[0] & 0x80) {
|
||||
second = this._xor(second, this.XOR_RIGHT);
|
||||
}
|
||||
|
||||
return { first: first, second: second };
|
||||
}
|
||||
|
||||
_getBlockCount(message) {
|
||||
const blockCount = Math.ceil(message.length / this.BLOCK_SIZE);
|
||||
return blockCount === 0 ? 1 : blockCount;
|
||||
}
|
||||
|
||||
async _aes(message) {
|
||||
const keyBuffer = forge.util.createBuffer(this._key, 'raw');
|
||||
const cipher = forge.cipher.createCipher('AES-CBC', keyBuffer);
|
||||
|
||||
const iv = forge.util.createBuffer(new Uint8Array(16), 'raw');
|
||||
cipher.start({
|
||||
iv: iv.getBytes()
|
||||
});
|
||||
|
||||
cipher.update(forge.util.createBuffer(message));
|
||||
cipher.finish();
|
||||
|
||||
const outputBuffer = cipher.output;
|
||||
return new Uint8Array(outputBuffer.getBytes().slice(0, 16).split('').map(c => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
_getLastBlock(message) {
|
||||
const blockCount = this._getBlockCount(message);
|
||||
const paddedBlock = this._padding(message, blockCount - 1);
|
||||
|
||||
let complete = false;
|
||||
if (message.length > 0) {
|
||||
complete = message.length % this.BLOCK_SIZE === 0;
|
||||
}
|
||||
|
||||
const key = complete ? this._subkeys.first : this._subkeys.second;
|
||||
return this._xor(paddedBlock, key);
|
||||
}
|
||||
|
||||
_padding(message, blockIndex) {
|
||||
const block = new Uint8Array(this.BLOCK_SIZE);
|
||||
|
||||
const from = blockIndex * this.BLOCK_SIZE;
|
||||
const slice = message.subarray(from, from + this.BLOCK_SIZE);
|
||||
block.set(slice);
|
||||
|
||||
if (slice.length !== this.BLOCK_SIZE) {
|
||||
block[slice.length] = 0x80;
|
||||
}
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
_bitShiftLeft(input) {
|
||||
const output = new Uint8Array(input.length);
|
||||
let overflow = 0;
|
||||
for (let i = input.length - 1; i >= 0; i--) {
|
||||
output[i] = (input[i] << 1) | overflow;
|
||||
overflow = input[i] & 0x80 ? 1 : 0;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
_xor(a, b) {
|
||||
const length = Math.min(a.length, b.length);
|
||||
const output = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
output[i] = a[i] ^ b[i];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
function uint8ArrayToBase64(uint8array) {
|
||||
return btoa(String.fromCharCode.apply(null, uint8array));
|
||||
}
|
||||
|
||||
function base64toUint8Array(base64_string){
|
||||
return Uint8Array.from(atob(base64_string), c => c.charCodeAt(0))
|
||||
}
|
||||
|
||||
function compareUint8Arrays(arr1, arr2) {
|
||||
if (arr1.length !== arr2.length)
|
||||
return false;
|
||||
return Array.from(arr1).every((value, index) => value === arr2[index]);
|
||||
}
|
||||
|
||||
function emitAndWaitForResponse(type, data) {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = Math.random().toString(16).substring(2, 9);
|
||||
|
||||
const responseHandler = (event) => {
|
||||
const { detail } = event;
|
||||
if (detail.substring(0, 7) === requestId) {
|
||||
document.removeEventListener('responseReceived', responseHandler);
|
||||
resolve(detail.substring(7));
|
||||
}
|
||||
};
|
||||
document.addEventListener('responseReceived', responseHandler);
|
||||
const requestEvent = new CustomEvent('response', {
|
||||
detail: {
|
||||
type: type,
|
||||
body: data,
|
||||
requestId: requestId,
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(requestEvent);
|
||||
});
|
||||
}
|
||||
|
||||
const fnproxy = (object, func) => new Proxy(object, { apply: func });
|
||||
const proxy = (object, key, func) => Object.hasOwnProperty.call(object, key) && Object.defineProperty(object, key, {
|
||||
value: fnproxy(object[key], func)
|
||||
});
|
||||
|
||||
function getEventListeners(type) {
|
||||
if (this == null) return [];
|
||||
const store = this[Symbol.for(getEventListeners)];
|
||||
if (store == null || store[type] == null) return [];
|
||||
return store[type];
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (typeof EventTarget !== 'undefined') {
|
||||
proxy(EventTarget.prototype, 'addEventListener', async (_target, _this, _args) => {
|
||||
if (_this != null) {
|
||||
const [type, listener] = _args;
|
||||
|
||||
const storeKey = Symbol.for(getEventListeners);
|
||||
if (!(storeKey in _this)) _this[storeKey] = {};
|
||||
|
||||
const store = _this[storeKey];
|
||||
if (!(type in store)) store[type] = [];
|
||||
const listeners = store[type];
|
||||
|
||||
let wrappedListener = listener;
|
||||
if (!!listener && !listener._isWrapped) {
|
||||
wrappedListener = async function(event) {
|
||||
if (event instanceof MediaKeyMessageEvent) {
|
||||
if (event._isCustomEvent) {
|
||||
if (listener.handleEvent) {
|
||||
listener.handleEvent(event);
|
||||
} else {
|
||||
listener.call(this, event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let newBody = new Uint8Array(event.message);
|
||||
if (!compareUint8Arrays(new Uint8Array([0x08, 0x04]), new Uint8Array(event.message))) {
|
||||
console.log("[WidevineProxy2]", "WIDEVINE_PROXY", "MESSAGE", listener);
|
||||
if (listener.name !== "messageHandler") {
|
||||
const oldChallenge = uint8ArrayToBase64(new Uint8Array(event.message));
|
||||
const newChallenge = await emitAndWaitForResponse("REQUEST", oldChallenge);
|
||||
if (oldChallenge !== newChallenge) {
|
||||
// Playback will fail if the challenges are the same (aka. the background script
|
||||
// returned the same challenge because the addon is disabled), but I still
|
||||
// override the challenge anyway, so check beforehand (in base64 form)
|
||||
newBody = base64toUint8Array(newChallenge);
|
||||
}
|
||||
} else {
|
||||
// trick EME Logger
|
||||
// better suggestions for avoiding EME Logger interference are welcome
|
||||
await emitAndWaitForResponse("REQUEST", "");
|
||||
}
|
||||
}
|
||||
|
||||
const newEvent = new MediaKeyMessageEvent('message', {
|
||||
isTrusted: event.isTrusted,
|
||||
bubbles: event.bubbles,
|
||||
cancelBubble: event.cancelBubble,
|
||||
composed: event.composed,
|
||||
currentTarget: event.currentTarget,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
eventPhase: event.eventPhase,
|
||||
message: newBody.buffer,
|
||||
messageType: event.messageType,
|
||||
returnValue: event.returnValue,
|
||||
srcElement: event.srcElement,
|
||||
target: event.target,
|
||||
timeStamp: event.timeStamp,
|
||||
});
|
||||
newEvent._isCustomEvent = true;
|
||||
|
||||
_this.dispatchEvent(newEvent);
|
||||
event.stopImmediatePropagation();
|
||||
return
|
||||
}
|
||||
|
||||
if (listener.handleEvent) {
|
||||
listener.handleEvent(event);
|
||||
} else {
|
||||
listener.call(this, event);
|
||||
}
|
||||
};
|
||||
|
||||
wrappedListener._isWrapped = true;
|
||||
wrappedListener.originalListener = listener;
|
||||
}
|
||||
|
||||
const alreadyAdded = listeners.some(
|
||||
storedListener => storedListener && storedListener.originalListener === listener
|
||||
);
|
||||
|
||||
if (!alreadyAdded) {
|
||||
listeners.push(wrappedListener);
|
||||
_args[1] = wrappedListener;
|
||||
}
|
||||
}
|
||||
return _target.apply(_this, _args);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof MediaKeySession !== 'undefined') {
|
||||
proxy(MediaKeySession.prototype, 'update', async (_target, _this, _args) => {
|
||||
const [response] = _args;
|
||||
console.log("[WidevineProxy2]", "WIDEVINE_PROXY", "UPDATE");
|
||||
await emitAndWaitForResponse("RESPONSE", uint8ArrayToBase64(new Uint8Array(response)))
|
||||
return await _target.apply(_this, _args);
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,65 @@
|
||||
const { ClientIdentification } = protobuf.roots.default.license_protocol;
|
||||
|
||||
export class Crc32 {
|
||||
constructor() {
|
||||
this.crc_table = this.setupTable();
|
||||
}
|
||||
|
||||
setupTable() {
|
||||
let c;
|
||||
const crcTable = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
crcTable[n] = c;
|
||||
}
|
||||
return crcTable;
|
||||
}
|
||||
|
||||
crc32(uint8Array) {
|
||||
let crc = 0 ^ (-1);
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
crc = (crc >>> 8) ^ this.crc_table[(crc ^ uint8Array[i]) & 0xFF];
|
||||
}
|
||||
return (crc ^ (-1)) >>> 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class WidevineDevice {
|
||||
constructor(bytes) {
|
||||
this._raw_bytes = new Uint8Array(bytes);
|
||||
this._data_view = new DataView(bytes);
|
||||
|
||||
this.version = this._data_view.getUint8(3);
|
||||
this.type = this._data_view.getUint8(4);
|
||||
this.security_level = this._data_view.getUint8(5);
|
||||
this.flags = this._data_view.getUint8(6);
|
||||
|
||||
this.private_key_len = this._data_view.getUint16(7);
|
||||
this.private_key = this._raw_bytes.subarray(9, 9 + this.private_key_len);
|
||||
|
||||
this.client_id_len = this._data_view.getUint16(9 + this.private_key_len);
|
||||
this.client_id_bytes = this._raw_bytes.subarray(11 + this.private_key_len, 11 + this.private_key_len + this.client_id_len);
|
||||
this.client_id = ClientIdentification.decode(this.client_id_bytes);
|
||||
}
|
||||
|
||||
get_name() {
|
||||
const client_info = Object.fromEntries(this.client_id.clientInfo.map(item => [item.name, item.value]))
|
||||
const type = this.type === 1 ? "CHROME" : `L${this.security_level}`
|
||||
|
||||
let name = `[${type}]`;
|
||||
if (client_info["company_name"])
|
||||
name += ` ${client_info["company_name"]}`;
|
||||
if (client_info["model_name"])
|
||||
name += `, ${client_info["model_name"]}`;
|
||||
if (client_info["product_name"])
|
||||
name += `, ${client_info["product_name"]}`;
|
||||
|
||||
const crc32 = new Crc32();
|
||||
name += `, ${crc32.crc32(this._raw_bytes).toString(16)}`;
|
||||
|
||||
return name;
|
||||
}
|
||||
}
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
+371
@@ -0,0 +1,371 @@
|
||||
import { AES_CMAC } from "./cmac.js"
|
||||
import {
|
||||
compareUint8Arrays,
|
||||
uint8ArrayToHex,
|
||||
uint8ArrayToString,
|
||||
stringToUint8Array,
|
||||
stringToHex,
|
||||
base64toUint8Array, uint8ArrayToBase64
|
||||
} from "./util.js"
|
||||
const { ClientIdentification, DrmCertificate, EncryptedClientIdentification, License, LicenseRequest, LicenseType,
|
||||
ProtocolVersion, SignedDrmCertificate, SignedMessage, WidevinePsshData } = protobuf.roots.default.license_protocol;
|
||||
|
||||
|
||||
export const SERVICE_CERTIFICATE_CHALLENGE = new Uint8Array([0x08, 0x04]);
|
||||
|
||||
const WIDEVINE_SYSTEM_ID = new Uint8Array([0xed, 0xef, 0x8b, 0xa9, 0x79, 0xd6, 0x4a, 0xce, 0xa3, 0xc8, 0x27, 0xdc, 0xd5, 0x1d, 0x21, 0xed]);
|
||||
const PSSH_MAGIC = new Uint8Array([0x70, 0x73, 0x73, 0x68])
|
||||
|
||||
const WIDEVINE_ROOT_PUBLIC_KEY = new Uint8Array([
|
||||
0x30, 0x82, 0x01, 0x8a, 0x02, 0x82, 0x01, 0x81, 0x00, 0xb4, 0xfe, 0x39, 0xc3, 0x65, 0x90, 0x03, 0xdb, 0x3c, 0x11, 0x97, 0x09, 0xe8, 0x68, 0xcd,
|
||||
0xf2, 0xc3, 0x5e, 0x9b, 0xf2, 0xe7, 0x4d, 0x23, 0xb1, 0x10, 0xdb, 0x87, 0x65, 0xdf, 0xdc, 0xfb, 0x9f, 0x35, 0xa0, 0x57, 0x03, 0x53, 0x4c, 0xf6,
|
||||
0x6d, 0x35, 0x7d, 0xa6, 0x78, 0xdb, 0xb3, 0x36, 0xd2, 0x3f, 0x9c, 0x40, 0xa9, 0x95, 0x26, 0x72, 0x7f, 0xb8, 0xbe, 0x66, 0xdf, 0xc5, 0x21, 0x98,
|
||||
0x78, 0x15, 0x16, 0x68, 0x5d, 0x2f, 0x46, 0x0e, 0x43, 0xcb, 0x8a, 0x84, 0x39, 0xab, 0xfb, 0xb0, 0x35, 0x80, 0x22, 0xbe, 0x34, 0x23, 0x8b, 0xab,
|
||||
0x53, 0x5b, 0x72, 0xec, 0x4b, 0xb5, 0x48, 0x69, 0x53, 0x3e, 0x47, 0x5f, 0xfd, 0x09, 0xfd, 0xa7, 0x76, 0x13, 0x8f, 0x0f, 0x92, 0xd6, 0x4c, 0xdf,
|
||||
0xae, 0x76, 0xa9, 0xba, 0xd9, 0x22, 0x10, 0xa9, 0x9d, 0x71, 0x45, 0xd6, 0xd7, 0xe1, 0x19, 0x25, 0x85, 0x9c, 0x53, 0x9a, 0x97, 0xeb, 0x84, 0xd7,
|
||||
0xcc, 0xa8, 0x88, 0x82, 0x20, 0x70, 0x26, 0x20, 0xfd, 0x7e, 0x40, 0x50, 0x27, 0xe2, 0x25, 0x93, 0x6f, 0xbc, 0x3e, 0x72, 0xa0, 0xfa, 0xc1, 0xbd,
|
||||
0x29, 0xb4, 0x4d, 0x82, 0x5c, 0xc1, 0xb4, 0xcb, 0x9c, 0x72, 0x7e, 0xb0, 0xe9, 0x8a, 0x17, 0x3e, 0x19, 0x63, 0xfc, 0xfd, 0x82, 0x48, 0x2b, 0xb7,
|
||||
0xb2, 0x33, 0xb9, 0x7d, 0xec, 0x4b, 0xba, 0x89, 0x1f, 0x27, 0xb8, 0x9b, 0x88, 0x48, 0x84, 0xaa, 0x18, 0x92, 0x0e, 0x65, 0xf5, 0xc8, 0x6c, 0x11,
|
||||
0xff, 0x6b, 0x36, 0xe4, 0x74, 0x34, 0xca, 0x8c, 0x33, 0xb1, 0xf9, 0xb8, 0x8e, 0xb4, 0xe6, 0x12, 0xe0, 0x02, 0x98, 0x79, 0x52, 0x5e, 0x45, 0x33,
|
||||
0xff, 0x11, 0xdc, 0xeb, 0xc3, 0x53, 0xba, 0x7c, 0x60, 0x1a, 0x11, 0x3d, 0x00, 0xfb, 0xd2, 0xb7, 0xaa, 0x30, 0xfa, 0x4f, 0x5e, 0x48, 0x77, 0x5b,
|
||||
0x17, 0xdc, 0x75, 0xef, 0x6f, 0xd2, 0x19, 0x6d, 0xdc, 0xbe, 0x7f, 0xb0, 0x78, 0x8f, 0xdc, 0x82, 0x60, 0x4c, 0xbf, 0xe4, 0x29, 0x06, 0x5e, 0x69,
|
||||
0x8c, 0x39, 0x13, 0xad, 0x14, 0x25, 0xed, 0x19, 0xb2, 0xf2, 0x9f, 0x01, 0x82, 0x0d, 0x56, 0x44, 0x88, 0xc8, 0x35, 0xec, 0x1f, 0x11, 0xb3, 0x24,
|
||||
0xe0, 0x59, 0x0d, 0x37, 0xe4, 0x47, 0x3c, 0xea, 0x4b, 0x7f, 0x97, 0x31, 0x1c, 0x81, 0x7c, 0x94, 0x8a, 0x4c, 0x7d, 0x68, 0x15, 0x84, 0xff, 0xa5,
|
||||
0x08, 0xfd, 0x18, 0xe7, 0xe7, 0x2b, 0xe4, 0x47, 0x27, 0x12, 0x11, 0xb8, 0x23, 0xec, 0x58, 0x93, 0x3c, 0xac, 0x12, 0xd2, 0x88, 0x6d, 0x41, 0x3d,
|
||||
0xc5, 0xfe, 0x1c, 0xdc, 0xb9, 0xf8, 0xd4, 0x51, 0x3e, 0x07, 0xe5, 0x03, 0x6f, 0xa7, 0x12, 0xe8, 0x12, 0xf7, 0xb5, 0xce, 0xa6, 0x96, 0x55, 0x3f,
|
||||
0x78, 0xb4, 0x64, 0x82, 0x50, 0xd2, 0x33, 0x5f, 0x91, 0x02, 0x03, 0x01, 0x00, 0x01
|
||||
]);
|
||||
|
||||
const STAGING_PRIVACY_CERT = new Uint8Array([
|
||||
0x0a, 0xbf, 0x02, 0x08, 0x03, 0x12, 0x10, 0x28, 0x70, 0x34, 0x54, 0xc0, 0x08, 0xf6, 0x36, 0x18, 0xad, 0xe7, 0x44, 0x3d, 0xb6, 0xc4, 0xc8, 0x18,
|
||||
0x8b, 0xe7, 0xf9, 0x90, 0x05, 0x22, 0x8e, 0x02, 0x30, 0x82, 0x01, 0x0a, 0x02, 0x82, 0x01, 0x01, 0x00, 0xb5, 0x21, 0x12, 0xb8, 0xd0, 0x5d, 0x02,
|
||||
0x3f, 0xcc, 0x5d, 0x95, 0xe2, 0xc2, 0x51, 0xc1, 0xc6, 0x49, 0xb4, 0x17, 0x7c, 0xd8, 0xd2, 0xbe, 0xef, 0x35, 0x5b, 0xb0, 0x67, 0x43, 0xde, 0x66,
|
||||
0x1e, 0x3d, 0x2a, 0xbc, 0x31, 0x82, 0xb7, 0x99, 0x46, 0xd5, 0x5f, 0xdc, 0x08, 0xdf, 0xe9, 0x54, 0x07, 0x81, 0x5e, 0x9a, 0x62, 0x74, 0xb3, 0x22,
|
||||
0xa2, 0xc7, 0xf5, 0xe0, 0x67, 0xbb, 0x5f, 0x0a, 0xc0, 0x7a, 0x89, 0xd4, 0x5a, 0xea, 0x94, 0xb2, 0x51, 0x6f, 0x07, 0x5b, 0x66, 0xef, 0x81, 0x1d,
|
||||
0x0d, 0x26, 0xe1, 0xb9, 0xa6, 0xb8, 0x94, 0xf2, 0xb9, 0x85, 0x79, 0x62, 0xaa, 0x17, 0x1c, 0x4f, 0x66, 0x63, 0x0d, 0x3e, 0x4c, 0x60, 0x27, 0x18,
|
||||
0x89, 0x7f, 0x5e, 0x1e, 0xf9, 0xb6, 0xaa, 0xf5, 0xad, 0x4d, 0xba, 0x2a, 0x7e, 0x14, 0x17, 0x6d, 0xf1, 0x34, 0xa1, 0xd3, 0x18, 0x5b, 0x5a, 0x21,
|
||||
0x8a, 0xc0, 0x5a, 0x4c, 0x41, 0xf0, 0x81, 0xef, 0xff, 0x80, 0xa3, 0xa0, 0x40, 0xc5, 0x0b, 0x09, 0xbb, 0xc7, 0x40, 0xee, 0xdc, 0xd8, 0xf1, 0x4d,
|
||||
0x67, 0x5a, 0x91, 0x98, 0x0f, 0x92, 0xca, 0x7d, 0xdc, 0x64, 0x6a, 0x06, 0xad, 0xad, 0x51, 0x01, 0xf7, 0x4a, 0x0e, 0x49, 0x8c, 0xc0, 0x1f, 0x00,
|
||||
0x53, 0x2b, 0xac, 0x21, 0x78, 0x50, 0xbd, 0x90, 0x5e, 0x90, 0x92, 0x36, 0x56, 0xb7, 0xdf, 0xef, 0xef, 0x42, 0x48, 0x67, 0x67, 0xf3, 0x3e, 0xf6,
|
||||
0x28, 0x3d, 0x4f, 0x42, 0x54, 0xab, 0x72, 0x58, 0x93, 0x90, 0xbe, 0xe5, 0x58, 0x08, 0xf1, 0xd6, 0x68, 0x08, 0x0d, 0x45, 0xd8, 0x93, 0xc2, 0xbc,
|
||||
0xa2, 0xf7, 0x4d, 0x60, 0xa0, 0xc0, 0xd0, 0xa0, 0x99, 0x3c, 0xef, 0x01, 0x60, 0x47, 0x03, 0x33, 0x4c, 0x36, 0x38, 0x13, 0x94, 0x86, 0xbc, 0x9d,
|
||||
0xaf, 0x24, 0xfd, 0x67, 0xa0, 0x7f, 0x9a, 0xd9, 0x43, 0x02, 0x03, 0x01, 0x00, 0x01, 0x3a, 0x12, 0x73, 0x74, 0x61, 0x67, 0x69, 0x6e, 0x67, 0x2e,
|
||||
0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x12, 0x80, 0x03, 0x98, 0x3e, 0x30, 0x35, 0x26, 0x75, 0xf4, 0x0b, 0xa7, 0x15, 0xfc,
|
||||
0x24, 0x9b, 0xda, 0xe5, 0xd4, 0xac, 0x72, 0x49, 0xa2, 0x66, 0x65, 0x21, 0xe4, 0x36, 0x55, 0x73, 0x95, 0x29, 0x72, 0x1f, 0xf8, 0x80, 0xe0, 0xaa,
|
||||
0xef, 0xc5, 0xe2, 0x7b, 0xc9, 0x80, 0xda, 0xea, 0xda, 0xbf, 0x3f, 0xc3, 0x86, 0xd0, 0x84, 0xa0, 0x2c, 0x82, 0x53, 0x78, 0x48, 0xcc, 0x75, 0x3f,
|
||||
0xf4, 0x97, 0xb0, 0x11, 0xa7, 0xda, 0x97, 0x78, 0x8a, 0x00, 0xe2, 0xaa, 0x6b, 0x84, 0xcd, 0x7d, 0x71, 0xc0, 0x7a, 0x48, 0xeb, 0xf6, 0x16, 0x02,
|
||||
0xcc, 0xa5, 0xa3, 0xf3, 0x20, 0x30, 0xa7, 0x29, 0x5c, 0x30, 0xda, 0x91, 0x5b, 0x91, 0xdc, 0x18, 0xb9, 0xbc, 0x95, 0x93, 0xb8, 0xde, 0x8b, 0xb5,
|
||||
0x0f, 0x0d, 0xed, 0xc1, 0x29, 0x38, 0xb8, 0xe9, 0xe0, 0x39, 0xcd, 0xde, 0x18, 0xfa, 0x82, 0xe8, 0x1b, 0xb0, 0x32, 0x63, 0x0f, 0xe9, 0x55, 0xd8,
|
||||
0x5a, 0x56, 0x6c, 0xe1, 0x54, 0x30, 0x0b, 0xf6, 0xd4, 0xc1, 0xbd, 0x12, 0x69, 0x66, 0x35, 0x6b, 0x28, 0x7d, 0x65, 0x7b, 0x18, 0xce, 0x63, 0xd0,
|
||||
0xef, 0xd4, 0x5f, 0xc5, 0x26, 0x9e, 0x97, 0xea, 0xb1, 0x1c, 0xb5, 0x63, 0xe5, 0x56, 0x43, 0xb2, 0x6f, 0xf4, 0x9f, 0x10, 0x9c, 0x21, 0x01, 0xaf,
|
||||
0xca, 0xf3, 0x5b, 0x83, 0x2f, 0x28, 0x8f, 0x0d, 0x9d, 0x45, 0x96, 0x0e, 0x25, 0x9e, 0x85, 0xfb, 0x5d, 0x24, 0xdb, 0xd2, 0xcf, 0x82, 0x76, 0x4c,
|
||||
0x5d, 0xd9, 0xbf, 0x72, 0x7e, 0xfb, 0xe9, 0xc8, 0x61, 0xf8, 0x69, 0x32, 0x1f, 0x6a, 0xde, 0x18, 0x90, 0x5f, 0x4d, 0x92, 0xf9, 0xa6, 0xda, 0x65,
|
||||
0x36, 0xdb, 0x84, 0x75, 0x87, 0x1d, 0x16, 0x8e, 0x87, 0x0b, 0xb2, 0x30, 0x3c, 0xf7, 0x0c, 0x6e, 0x97, 0x84, 0xc9, 0x3d, 0x2d, 0xe8, 0x45, 0xad,
|
||||
0x82, 0x62, 0xbe, 0x7e, 0x0d, 0x4e, 0x2e, 0x4a, 0x07, 0x59, 0xce, 0xf8, 0x2d, 0x10, 0x9d, 0x25, 0x92, 0xc7, 0x24, 0x29, 0xf8, 0xc0, 0x17, 0x42,
|
||||
0xba, 0xe2, 0xb3, 0xde, 0xca, 0xdb, 0xc3, 0x3c, 0x3e, 0x5f, 0x4b, 0xaf, 0x5e, 0x16, 0xec, 0xb7, 0x4e, 0xad, 0xba, 0xfc, 0xb7, 0xc6, 0x70, 0x5f,
|
||||
0x7a, 0x9e, 0x3b, 0x6f, 0x39, 0x40, 0x38, 0x3f, 0x9c, 0x51, 0x16, 0xd2, 0x02, 0xa2, 0x0c, 0x92, 0x29, 0xee, 0x96, 0x9c, 0x25, 0x19, 0x71, 0x83,
|
||||
0x03, 0xb5, 0x0d, 0x01, 0x30, 0xc3, 0x35, 0x2e, 0x06, 0xb0, 0x14, 0xd8, 0x38, 0x54, 0x0f, 0x8a, 0x0c, 0x22, 0x7c, 0x00, 0x11, 0xe0, 0xf5, 0xb3,
|
||||
0x8e, 0x4e, 0x29, 0x8e, 0xd2, 0xcb, 0x30, 0x1e, 0xb4, 0x56, 0x49, 0x65, 0xf5, 0x5c, 0x5d, 0x79, 0x75, 0x7a, 0x25, 0x0a, 0x4e, 0xb9, 0xc8, 0x4a,
|
||||
0xb3, 0xe6, 0x53, 0x9f, 0x6b, 0x6f, 0xdf, 0x56, 0x89, 0x9e, 0xa2, 0x99, 0x14
|
||||
])
|
||||
|
||||
|
||||
export class Session {
|
||||
constructor(contentDecryptionModule, pssh) {
|
||||
this._devicePrivateKey = forge.pki.privateKeyFromPem(
|
||||
contentDecryptionModule.privateKey
|
||||
)
|
||||
this._identifierBlob = ClientIdentification.decode(
|
||||
contentDecryptionModule.identifierBlob
|
||||
)
|
||||
if (typeof pssh === "string") {
|
||||
pssh = base64toUint8Array(pssh)
|
||||
}
|
||||
this._pssh = pssh
|
||||
}
|
||||
|
||||
async setDefaultServiceCertificate() {
|
||||
await this.setServiceCertificate(STAGING_PRIVACY_CERT)
|
||||
}
|
||||
|
||||
async setServiceCertificateFromMessage(rawSignedMessage) {
|
||||
const signedMessage = SignedMessage.decode(new Uint8Array(rawSignedMessage))
|
||||
if (!signedMessage.msg) {
|
||||
throw new Error(
|
||||
"the service certificate message does not contain a message"
|
||||
)
|
||||
}
|
||||
await this.setServiceCertificate(signedMessage.msg)
|
||||
}
|
||||
|
||||
async setServiceCertificate(serviceCertificate) {
|
||||
const signedServiceCertificate = SignedDrmCertificate.decode(
|
||||
serviceCertificate
|
||||
)
|
||||
if (!(await this._verifyServiceCertificate(signedServiceCertificate))) {
|
||||
throw new Error(
|
||||
"Service certificate is not signed by the Widevine root certificate"
|
||||
)
|
||||
}
|
||||
this._serviceCertificate = signedServiceCertificate
|
||||
}
|
||||
|
||||
createLicenseRequest(licenseType = LicenseType.STREAMING, android = false) {
|
||||
if (compareUint8Arrays(this._pssh.subarray(4, 8), PSSH_MAGIC)) {
|
||||
if (compareUint8Arrays(this._pssh.subarray(12, 28), WIDEVINE_SYSTEM_ID)) {
|
||||
this._pssh = this._pssh.subarray(32)
|
||||
} else {
|
||||
throw new Error(`Invalid System ID in PSSH Box: ${uint8ArrayToHex(this._pssh.subarray(12, 28))}`)
|
||||
}
|
||||
}
|
||||
|
||||
const pssh = this._parseWidevinePsshData(this._pssh)
|
||||
if (!pssh) {
|
||||
throw new Error("Invalid WidevinePsshData found")
|
||||
}
|
||||
|
||||
const request_id = android ? this._generateAndroidIdentifier() : this._generateGenericIdentifier();
|
||||
const licenseRequest = new LicenseRequest({
|
||||
type: LicenseRequest.RequestType.NEW,
|
||||
contentId: new LicenseRequest.ContentIdentification({
|
||||
widevinePsshData: new LicenseRequest.ContentIdentification.WidevinePsshData({
|
||||
psshData: [this._pssh],
|
||||
licenseType: licenseType,
|
||||
requestId: request_id
|
||||
})
|
||||
}),
|
||||
requestTime: Math.floor(Date.now() / 1000), // BigInt(Date.now()) / BigInt(1000),
|
||||
protocolVersion: ProtocolVersion.VERSION_2_1,
|
||||
keyControlNonce: Math.floor(Math.random() * 2 ** 31)
|
||||
})
|
||||
|
||||
if (this._serviceCertificate) {
|
||||
licenseRequest.encryptedClientId = this._encryptClientIdentification(
|
||||
this._identifierBlob,
|
||||
this._serviceCertificate
|
||||
)
|
||||
} else {
|
||||
licenseRequest.clientId = this._identifierBlob
|
||||
}
|
||||
|
||||
this._rawLicenseRequest = LicenseRequest.encode(licenseRequest).finish()
|
||||
|
||||
const pss = forge.pss.create({
|
||||
md: forge.md.sha1.create(),
|
||||
mgf: forge.mgf.mgf1.create(forge.md.sha1.create()),
|
||||
saltLength: 20
|
||||
})
|
||||
|
||||
const md = forge.md.sha1.create()
|
||||
md.update(uint8ArrayToString(this._rawLicenseRequest), "raw")
|
||||
|
||||
const signature = stringToUint8Array(this._devicePrivateKey.sign(md, pss))
|
||||
const signedLicenseRequest = new SignedMessage({
|
||||
type: SignedMessage.MessageType.LICENSE_REQUEST,
|
||||
msg: this._rawLicenseRequest,
|
||||
signature: signature
|
||||
})
|
||||
|
||||
return [SignedMessage.encode(signedLicenseRequest).finish(), request_id]
|
||||
}
|
||||
|
||||
async parseLicense(rawLicense) {
|
||||
if (!this._rawLicenseRequest) {
|
||||
throw new Error("please request a license first")
|
||||
}
|
||||
|
||||
const signedLicense = SignedMessage.decode(new Uint8Array(rawLicense))
|
||||
if (!signedLicense.sessionKey) {
|
||||
throw new Error("the license does not contain a session key")
|
||||
}
|
||||
if (!signedLicense.msg) {
|
||||
throw new Error("the license does not contain a message")
|
||||
}
|
||||
if (!signedLicense.signature) {
|
||||
throw new Error("the license does not contain a signature")
|
||||
}
|
||||
|
||||
const sessionKey = this._devicePrivateKey.decrypt(
|
||||
uint8ArrayToString(signedLicense.sessionKey),
|
||||
"RSA-OAEP",
|
||||
{
|
||||
md: forge.md.sha1.create()
|
||||
}
|
||||
)
|
||||
|
||||
const cmac = new AES_CMAC(stringToUint8Array(sessionKey))
|
||||
const encKeyBase = new Uint8Array([
|
||||
...stringToUint8Array("ENCRYPTION"),
|
||||
...new Uint8Array([0x00]),
|
||||
...this._rawLicenseRequest,
|
||||
...new Uint8Array([0x00, 0x00, 0x00, 0x80])
|
||||
])
|
||||
const authKeyBase = new Uint8Array([
|
||||
...stringToUint8Array("AUTHENTICATION"),
|
||||
...new Uint8Array([0x00]),
|
||||
...this._rawLicenseRequest,
|
||||
...new Uint8Array([0x00, 0x00, 0x02, 0x00])
|
||||
])
|
||||
|
||||
const encKey = await cmac.calculate(
|
||||
new Uint8Array([
|
||||
...new Uint8Array([0x01]),
|
||||
...encKeyBase
|
||||
])
|
||||
)
|
||||
|
||||
const server_key_1 = await cmac.calculate(new Uint8Array([
|
||||
...new Uint8Array([0x01]),
|
||||
...authKeyBase
|
||||
]))
|
||||
const server_key_2 = await cmac.calculate(new Uint8Array([
|
||||
...new Uint8Array([0x02]),
|
||||
...authKeyBase
|
||||
]))
|
||||
const serverKey = new Uint8Array([
|
||||
...new Uint8Array(server_key_1),
|
||||
...new Uint8Array(server_key_2)
|
||||
])
|
||||
|
||||
const hmac = forge.hmac.create()
|
||||
hmac.start(forge.md.sha256.create(), uint8ArrayToString(serverKey), "raw")
|
||||
hmac.update(uint8ArrayToString(signedLicense.msg))
|
||||
const calculatedSignature = stringToUint8Array(hmac.digest().data)
|
||||
|
||||
if (!compareUint8Arrays(calculatedSignature, signedLicense.signature)) {
|
||||
throw new Error("signatures do not match")
|
||||
}
|
||||
|
||||
const license = License.decode(signedLicense.msg)
|
||||
|
||||
const keyContainers = license.key.map(keyContainer => {
|
||||
if (keyContainer.type && keyContainer.type === 2 && keyContainer.key && keyContainer.iv) {
|
||||
const keyBuffer = forge.util.createBuffer(encKey, 'raw');
|
||||
const decipher = forge.cipher.createDecipher(
|
||||
"AES-CBC",
|
||||
keyBuffer
|
||||
)
|
||||
|
||||
decipher.start({
|
||||
iv: uint8ArrayToString(keyContainer.iv)
|
||||
})
|
||||
decipher.update(forge.util.createBuffer(keyContainer.key))
|
||||
decipher.finish()
|
||||
|
||||
return {
|
||||
kid: keyContainer.id.length !== 0 ? uint8ArrayToHex(keyContainer.id) : "00000000000000000000000000000000",
|
||||
k: stringToHex(decipher.output.data)
|
||||
}
|
||||
}
|
||||
})
|
||||
const valid_containers = keyContainers.filter(container => !!container);
|
||||
if (valid_containers.length < 1) {
|
||||
throw new Error("there was not a single valid key in the response")
|
||||
}
|
||||
return valid_containers;
|
||||
}
|
||||
|
||||
_encryptClientIdentification(clientIdentification, signedServiceCertificate) {
|
||||
if (!signedServiceCertificate.drmCertificate) {
|
||||
throw new Error(
|
||||
"the service certificate does not contain an actual certificate"
|
||||
)
|
||||
}
|
||||
|
||||
const serviceCertificate = DrmCertificate.decode(
|
||||
signedServiceCertificate.drmCertificate
|
||||
)
|
||||
console.log("[WidevineProxy2]", "SERVICE_CERTIFICATE", serviceCertificate);
|
||||
|
||||
if (!serviceCertificate.publicKey) {
|
||||
throw new Error("the service certificate does not contain a public key")
|
||||
}
|
||||
|
||||
const key = forge.random.getBytesSync(16)
|
||||
const iv = forge.random.getBytesSync(16)
|
||||
|
||||
const cipher = forge.cipher.createCipher("AES-CBC", key)
|
||||
cipher.start({
|
||||
iv: iv
|
||||
})
|
||||
cipher.update(
|
||||
forge.util.createBuffer(
|
||||
ClientIdentification.encode(clientIdentification).finish()
|
||||
)
|
||||
)
|
||||
cipher.finish()
|
||||
const rawEncryptedClientIdentification = stringToUint8Array(cipher.output.data)
|
||||
|
||||
const publicKey = forge.pki.publicKeyFromAsn1(
|
||||
forge.asn1.fromDer(
|
||||
uint8ArrayToString(serviceCertificate.publicKey)
|
||||
)
|
||||
)
|
||||
const encryptedKey = publicKey.encrypt(key, "RSA-OAEP", {
|
||||
md: forge.md.sha1.create()
|
||||
})
|
||||
|
||||
return new EncryptedClientIdentification({
|
||||
encryptedClientId: rawEncryptedClientIdentification,
|
||||
encryptedClientIdIv: stringToUint8Array(iv),
|
||||
encryptedPrivacyKey: stringToUint8Array(encryptedKey),
|
||||
providerId: serviceCertificate.providerId,
|
||||
serviceCertificateSerialNumber: serviceCertificate.serialNumber
|
||||
})
|
||||
}
|
||||
|
||||
async _verifyServiceCertificate(signedServiceCertificate) {
|
||||
if (!signedServiceCertificate.drmCertificate) {
|
||||
throw new Error(
|
||||
"the service certificate does not contain an actual certificate"
|
||||
)
|
||||
}
|
||||
if (!signedServiceCertificate.signature) {
|
||||
throw new Error("the service certificate does not contain a signature")
|
||||
}
|
||||
|
||||
const pss = forge.pss.create({
|
||||
md: forge.md.sha1.create(),
|
||||
mgf: forge.mgf.mgf1.create(forge.md.sha1.create()),
|
||||
saltLength: 20
|
||||
})
|
||||
|
||||
const sha1 = forge.md.sha1.create()
|
||||
sha1.update(
|
||||
uint8ArrayToString(signedServiceCertificate.drmCertificate),
|
||||
"raw"
|
||||
)
|
||||
|
||||
const publicKey = forge.pki.publicKeyFromAsn1(
|
||||
forge.asn1.fromDer(
|
||||
uint8ArrayToString(WIDEVINE_ROOT_PUBLIC_KEY)
|
||||
)
|
||||
)
|
||||
|
||||
return publicKey.verify(
|
||||
sha1.digest().bytes(),
|
||||
uint8ArrayToString(signedServiceCertificate.signature),
|
||||
pss
|
||||
)
|
||||
}
|
||||
|
||||
_parseWidevinePsshData(pssh) {
|
||||
try {
|
||||
return WidevinePsshData.decode(pssh)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
_generateAndroidIdentifier() {
|
||||
return stringToUint8Array(`${forge.util.bytesToHex(forge.random.getBytesSync(8))}0100000000000000`)
|
||||
}
|
||||
|
||||
_generateGenericIdentifier() {
|
||||
return forge.random.getBytesSync(16)
|
||||
}
|
||||
|
||||
getPSSH() {
|
||||
return uint8ArrayToBase64(this._pssh);
|
||||
}
|
||||
}
|
||||
+11727
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "WidevineProxy2",
|
||||
"version": "0.2",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"tabs",
|
||||
"storage",
|
||||
"unlimitedStorage"
|
||||
],
|
||||
"host_permissions": [
|
||||
"*://*/*"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "panel/panel.html",
|
||||
"default_icon": {
|
||||
"128": "images/icon-128.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"128": "images/icon-128.png"
|
||||
},
|
||||
"background": {
|
||||
"scripts": ["background.js"],
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["message_proxy.js"],
|
||||
"run_at": "document_start",
|
||||
"world": "ISOLATED",
|
||||
"all_frames": true
|
||||
},
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content_script.js"],
|
||||
"run_at": "document_start",
|
||||
"world": "MAIN",
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "DevLARLEY@larley.dev",
|
||||
"strict_min_version": "58.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
async function processMessage(detail) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({
|
||||
type: detail.type,
|
||||
body: detail.body,
|
||||
}, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
return reject(chrome.runtime.lastError);
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
document.addEventListener('response', async (event) => {
|
||||
const { detail } = event;
|
||||
const responseData = await processMessage(detail);
|
||||
const responseEvent = new CustomEvent('responseReceived', {
|
||||
detail: detail.requestId.concat(responseData)
|
||||
});
|
||||
document.dispatchEvent(responseEvent);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header > * {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
#wvd > * {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.right-bound {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-box {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.expandableDiv {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background-color: lightblue;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.expandableDiv.expanded {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.expandableDiv.collapsed {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.always-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.expanded-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.expandableDiv.expanded .expanded-only {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title></title>
|
||||
<script type="module" src="panel.js" defer></script>
|
||||
<link rel="stylesheet" href="panel.css">
|
||||
</head>
|
||||
<body style="min-width: 200px;">
|
||||
<div class="header">
|
||||
<img src="../images/icon-128.png" alt="WidevineProxy Icon" width="64"/>
|
||||
<img src="../images/proxy_text.png" alt="ProxyText" width="200"/>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend>Settings</legend>
|
||||
<input type="checkbox" id="enabled">
|
||||
<label for="enabled"> Enabled</label><br>
|
||||
<fieldset id="wvd">
|
||||
<legend>Widevine Device</legend>
|
||||
<button type="button" id="fileInput">Choose File</button><br>
|
||||
<label>
|
||||
<select id="wvd-combobox"></select>
|
||||
</label><br>
|
||||
<button type="button" id="remove">Remove</button>
|
||||
<button type="button" id="download">Download</button>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Keys</legend>
|
||||
<button type="button" id="clear">Clear</button>
|
||||
<div id="key-container"></div>
|
||||
</fieldset>
|
||||
</body>
|
||||
</html>
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import "../protobuf.min.js";
|
||||
import "../license_protocol.js";
|
||||
import { base64toUint8Array, DeviceManager, SettingsManager } from "../util.js";
|
||||
|
||||
const key_container = document.getElementById('key-container');
|
||||
|
||||
const enabled = document.getElementById('enabled');
|
||||
enabled.addEventListener('change', async function (){
|
||||
await SettingsManager.setEnabled(enabled.checked);
|
||||
});
|
||||
|
||||
const wvd_combobox = document.getElementById('wvd-combobox');
|
||||
wvd_combobox.addEventListener('change', async function() {
|
||||
await DeviceManager.saveSelectedWidevineDevice(wvd_combobox.options[wvd_combobox.selectedIndex].text);
|
||||
});
|
||||
|
||||
const remove = document.getElementById('remove');
|
||||
remove.addEventListener('click', async function() {
|
||||
await DeviceManager.removeSelectedWidevineDevice();
|
||||
wvd_combobox.innerHTML = '';
|
||||
await DeviceManager.loadSetAllWidevineDevices();
|
||||
const selected_option = wvd_combobox.options[wvd_combobox.selectedIndex];
|
||||
if (selected_option) {
|
||||
await DeviceManager.saveSelectedWidevineDevice(selected_option.text);
|
||||
}
|
||||
});
|
||||
|
||||
const download = document.getElementById('download');
|
||||
download.addEventListener('click', async function() {
|
||||
const widevine_device = await DeviceManager.getSelectedWidevineDevice();
|
||||
SettingsManager.downloadFile(
|
||||
base64toUint8Array(await DeviceManager.loadWidevineDevice(widevine_device)),
|
||||
widevine_device + ".wvd"
|
||||
)
|
||||
});
|
||||
|
||||
const clear = document.getElementById('clear');
|
||||
clear.addEventListener('click', async function() {
|
||||
chrome.runtime.sendMessage({ type: "CLEAR" });
|
||||
key_container.innerHTML = "";
|
||||
});
|
||||
|
||||
document.getElementById('fileInput').addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ type: "OPEN_PICKER" });
|
||||
window.close();
|
||||
});
|
||||
|
||||
function appendLog(result) {
|
||||
const key_string = result.keys.map(key => `--key ${key.kid}:${key.k}`).join(' ');
|
||||
const date = new Date(result.timestamp * 1000);
|
||||
const date_string = date.toLocaleString();
|
||||
|
||||
key_container.innerHTML += `
|
||||
<div class="log-container">
|
||||
<button class="toggleButton">+</button>
|
||||
<div class="expandableDiv collapsed">
|
||||
<label class="always-visible right-bound">
|
||||
URL:<input id="url" type="text" class="text-box" value="${result.url}">
|
||||
</label>
|
||||
<label class="expanded-only right-bound">
|
||||
PSSH:<input id="pssh" type="text" class="text-box" value="${result.pssh_data}">
|
||||
</label>
|
||||
<label class="expanded-only right-bound">
|
||||
Keys:<input id="keys" type="text" class="text-box" value="${key_string}">
|
||||
</label>
|
||||
<label class="expanded-only right-bound">
|
||||
Date:<input id="date" type="text" class="text-box" value="${date_string}">
|
||||
</label>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function applyListeners() {
|
||||
const toggleButtons = document.querySelectorAll('.toggleButton');
|
||||
toggleButtons.forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
const expandableDiv = this.nextElementSibling;
|
||||
if (expandableDiv.classList.contains('collapsed')) {
|
||||
expandableDiv.classList.remove('collapsed');
|
||||
expandableDiv.classList.add('expanded');
|
||||
} else {
|
||||
expandableDiv.classList.remove('expanded');
|
||||
expandableDiv.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||||
if (areaName === 'local') {
|
||||
for (const [key, values] of Object.entries(changes)) {
|
||||
appendLog(values.newValue);
|
||||
applyListeners();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function checkLogs() {
|
||||
chrome.runtime.sendMessage({ type: "GET_LOGS" }, function(response) {
|
||||
if (response) {
|
||||
response.forEach((result) => {
|
||||
appendLog(result);
|
||||
});
|
||||
applyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function () {
|
||||
enabled.checked = await SettingsManager.getEnabled();
|
||||
await DeviceManager.loadSetAllWidevineDevices();
|
||||
await DeviceManager.selectWidevineDevice(await DeviceManager.getSelectedWidevineDevice());
|
||||
checkLogs();
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<body>
|
||||
<input type="file" id="fileInput" accept=".wvd"/>
|
||||
<script type="module" src="filePicker.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
import "../protobuf.min.js";
|
||||
import "../license_protocol.js";
|
||||
import { SettingsManager } from "../util.js";
|
||||
|
||||
document.getElementById('fileInput').addEventListener('change', async (event) => {
|
||||
const file = event.target.files[0];
|
||||
await SettingsManager.importDevice(file).then(() => {
|
||||
window.close();
|
||||
});
|
||||
});
|
||||
Vendored
+8
File diff suppressed because one or more lines are too long
@@ -0,0 +1,213 @@
|
||||
import { WidevineDevice } from "./device.js";
|
||||
|
||||
export class AsyncSyncStorage {
|
||||
static async setStorage(items) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.sync.set(items, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static async getStorage(keys) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.sync.get(keys, (result) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static async removeStorage(keys) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.sync.remove(keys, (result) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class AsyncLocalStorage {
|
||||
static async setStorage(items) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.local.set(items, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static async getStorage(keys) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.local.get(keys, (result) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static async removeStorage(keys) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.local.remove(keys, (result) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class DeviceManager {
|
||||
static async saveWidevineDevice(name, value) {
|
||||
const result = await AsyncSyncStorage.getStorage(['devices']);
|
||||
const array = result.devices === undefined ? [] : result.devices;
|
||||
array.push(name);
|
||||
await AsyncSyncStorage.setStorage({ devices: array });
|
||||
|
||||
await AsyncSyncStorage.setStorage({ [name]: value });
|
||||
}
|
||||
|
||||
static async loadWidevineDevice(name) {
|
||||
const result = await AsyncSyncStorage.getStorage([name]);
|
||||
return result[name] || "";
|
||||
}
|
||||
|
||||
static setWidevineDevice(name, value){
|
||||
const wvd_combobox = document.getElementById('wvd-combobox');
|
||||
const wvd_element = document.createElement('option');
|
||||
|
||||
wvd_element.text = name;
|
||||
wvd_element.value = value;
|
||||
|
||||
wvd_combobox.appendChild(wvd_element);
|
||||
}
|
||||
|
||||
static async loadSetAllWidevineDevices() {
|
||||
const result = await AsyncSyncStorage.getStorage(['devices']);
|
||||
const array = result.devices || [];
|
||||
for (const item of array) {
|
||||
this.setWidevineDevice(item, await this.loadWidevineDevice(item));
|
||||
}
|
||||
}
|
||||
|
||||
static async saveSelectedWidevineDevice(name) {
|
||||
await AsyncSyncStorage.setStorage({ selected: name });
|
||||
}
|
||||
|
||||
static async getSelectedWidevineDevice() {
|
||||
const result = await AsyncSyncStorage.getStorage(["selected"]);
|
||||
return result["selected"] || "";
|
||||
}
|
||||
|
||||
static async selectWidevineDevice(name) {
|
||||
document.getElementById('wvd-combobox').value = await this.loadWidevineDevice(name);
|
||||
}
|
||||
|
||||
static async removeSelectedWidevineDevice() {
|
||||
const selected_device_name = await DeviceManager.getSelectedWidevineDevice();
|
||||
|
||||
const result = await AsyncSyncStorage.getStorage(['devices']);
|
||||
const array = result.devices === undefined ? [] : result.devices;
|
||||
|
||||
const index = array.indexOf(selected_device_name);
|
||||
if (index > -1) {
|
||||
array.splice(index, 1);
|
||||
}
|
||||
|
||||
await AsyncSyncStorage.setStorage({ devices: array });
|
||||
await AsyncSyncStorage.removeStorage([selected_device_name]);
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingsManager {
|
||||
static async setEnabled(enabled) {
|
||||
await AsyncSyncStorage.setStorage({ enabled: enabled });
|
||||
}
|
||||
|
||||
static async getEnabled() {
|
||||
const result = await AsyncSyncStorage.getStorage(["enabled"]);
|
||||
return result["enabled"] === undefined ? false : result["enabled"];
|
||||
}
|
||||
|
||||
static downloadFile(content, filename) {
|
||||
const blob = new Blob([content], { type: 'application/octet-stream' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
static async importDevice(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async function (loaded) {
|
||||
const result = loaded.target.result;
|
||||
|
||||
const widevine_device = new WidevineDevice(result);
|
||||
const b64_device = uint8ArrayToBase64(new Uint8Array(result));
|
||||
const device_name = widevine_device.get_name();
|
||||
|
||||
if (!await DeviceManager.loadWidevineDevice(device_name)) {
|
||||
await DeviceManager.saveWidevineDevice(device_name, b64_device);
|
||||
}
|
||||
|
||||
await DeviceManager.saveSelectedWidevineDevice(device_name);
|
||||
resolve();
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function compareUint8Arrays(arr1, arr2) {
|
||||
if (arr1.length !== arr2.length)
|
||||
return false;
|
||||
return Array.from(arr1).every((value, index) => value === arr2[index]);
|
||||
}
|
||||
|
||||
export function uint8ArrayToHex(buffer) {
|
||||
return Array.prototype.map.call(buffer, x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function uint8ArrayToString(uint8array) {
|
||||
return String.fromCharCode.apply(null, uint8array)
|
||||
}
|
||||
|
||||
export function uint8ArrayToBase64(uint8array) {
|
||||
return btoa(String.fromCharCode.apply(null, uint8array));
|
||||
}
|
||||
|
||||
export function base64toUint8Array(base64_string){
|
||||
return Uint8Array.from(atob(base64_string), c => c.charCodeAt(0))
|
||||
}
|
||||
|
||||
export function stringToUint8Array(string) {
|
||||
return Uint8Array.from(string.split("").map(x => x.charCodeAt()))
|
||||
}
|
||||
|
||||
export function stringToHex(string){
|
||||
return string.split("").map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
Reference in New Issue
Block a user