├── .gitignore ├── src ├── images │ ├── icon.png │ ├── icon-active.png │ ├── icon-closed.png │ └── icon-disabled.png ├── pages │ ├── picker │ │ ├── filePicker.css │ │ ├── filePicker.html │ │ └── filePicker.js │ ├── errview │ │ ├── errview.css │ │ ├── errview.html │ │ └── errview.js │ └── panel │ │ ├── panel.css │ │ ├── panel.html │ │ └── panel.js ├── lib │ ├── playready │ │ ├── key.js │ │ ├── xml_key.js │ │ ├── device.js │ │ ├── elgamal.js │ │ ├── ecc_key.js │ │ ├── crypto.js │ │ ├── utils.js │ │ ├── main.js │ │ ├── xmr_license.js │ │ ├── cmac.js │ │ └── cdm.js │ ├── customhandlers │ │ ├── main.js │ │ ├── hardcoded.js │ │ └── knownkeys.js │ ├── widevine │ │ ├── main.js │ │ ├── device.js │ │ ├── cmac.js │ │ ├── license.js │ │ └── protobuf.min.js │ └── remote_cdm.js ├── message_proxy.js ├── manifest.json └── background.js ├── devices ├── cdrm_pr.json ├── cdrm_wv.json ├── sg_sample_prproxy_api.json ├── sg_sample_cdrm_pr.json ├── kxremote_sg_wv_trial.json └── sg_sample_cdrm_wv.json ├── package.json ├── rollup.config.js ├── logo.svg └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.zip 2 | *.xpi 3 | *.crx 4 | devices/private 5 | dist/ 6 | node_modules/ -------------------------------------------------------------------------------- /src/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ingan121/Vineless/HEAD/src/images/icon.png -------------------------------------------------------------------------------- /src/images/icon-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ingan121/Vineless/HEAD/src/images/icon-active.png -------------------------------------------------------------------------------- /src/images/icon-closed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ingan121/Vineless/HEAD/src/images/icon-closed.png -------------------------------------------------------------------------------- /src/images/icon-disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ingan121/Vineless/HEAD/src/images/icon-disabled.png -------------------------------------------------------------------------------- /src/pages/picker/filePicker.css: -------------------------------------------------------------------------------- 1 | @media (prefers-color-scheme: dark) { 2 | body { 3 | background-color: black; 4 | color: white; 5 | color-scheme: dark; 6 | } 7 | } -------------------------------------------------------------------------------- /devices/cdrm_pr.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "PLAYREADY", 3 | "device_name": "public", 4 | "host": "https://cdrm-project.com/remotecdm/playready", 5 | "name_override": "CDRM-Project Public PlayReady API", 6 | "security_level": "3000", 7 | "secret": "CDRM" 8 | } 9 | -------------------------------------------------------------------------------- /devices/cdrm_wv.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "WIDEVINE", 3 | "device_name": "public", 4 | "device_type": "ANDROID", 5 | "host": "https://cdrm-project.com/remotecdm/widevine", 6 | "name_override": "CDRM-Project Public Widevine API", 7 | "secret": "CDRM", 8 | "security_level": 3, 9 | "system_id": 22590 10 | } -------------------------------------------------------------------------------- /src/pages/errview/errview.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | } 4 | 5 | #message { 6 | white-space: pre-wrap; 7 | overflow-wrap: break-word; 8 | } 9 | 10 | #openErrors { 11 | margin-right: 5px; 12 | } 13 | 14 | a { 15 | text-decoration: none; 16 | } 17 | 18 | a:hover { 19 | text-decoration: underline; 20 | } 21 | 22 | @media (prefers-color-scheme: dark) { 23 | body { 24 | background-color: black; 25 | color: white; 26 | color-scheme: dark; 27 | } 28 | a { 29 | color: dodgerblue; 30 | } 31 | } -------------------------------------------------------------------------------- /src/lib/playready/key.js: -------------------------------------------------------------------------------- 1 | export class Key { 2 | constructor(key_id, key_type, cipher_type, key) { 3 | this.key_id = this._swapEndianess(key_id); 4 | this.key_type = key_type; 5 | this.cipher_type = cipher_type; 6 | this.key = key; 7 | } 8 | 9 | _swapEndianess(uuidBytes) { 10 | return new Uint8Array([ 11 | uuidBytes[3], uuidBytes[2], uuidBytes[1], uuidBytes[0], 12 | uuidBytes[5], uuidBytes[4], 13 | uuidBytes[7], uuidBytes[6], 14 | uuidBytes[8], uuidBytes[9], 15 | ...uuidBytes.slice(10, 16) 16 | ]); 17 | } 18 | } -------------------------------------------------------------------------------- /src/lib/playready/xml_key.js: -------------------------------------------------------------------------------- 1 | import { EccKey } from "./ecc_key.js"; 2 | import { utils } from "./noble-curves.min.js"; 3 | 4 | export class XmlKey { 5 | constructor() { 6 | this._shared_point = EccKey.generate(); 7 | this.shared_x_key = this._shared_point.publicKey.x; 8 | this.shared_y_key = this._shared_point.publicKey.y; 9 | 10 | const shared_key_x_bytes = utils.numberToBytesBE(this.shared_x_key, 32); 11 | this.aes_iv = shared_key_x_bytes.subarray(0, 16); 12 | this.aes_key = shared_key_x_bytes.subarray(16, 32); 13 | } 14 | 15 | get_point() { 16 | return this._shared_point.publicKey; 17 | } 18 | } -------------------------------------------------------------------------------- /src/message_proxy.js: -------------------------------------------------------------------------------- 1 | async function processMessage(detail) { 2 | return new Promise((resolve, reject) => { 3 | chrome.runtime.sendMessage({ 4 | type: detail.type, 5 | body: detail.body, 6 | from: "content" 7 | }, (response) => { 8 | if (chrome.runtime.lastError) { 9 | return reject(chrome.runtime.lastError); 10 | } 11 | resolve(response); 12 | }); 13 | }) 14 | } 15 | 16 | document.addEventListener('response', async (event) => { 17 | const { detail } = event; 18 | const responseData = await processMessage(detail); 19 | const responseEvent = new CustomEvent('responseReceived', { 20 | detail: detail.requestId.concat(responseData) 21 | }); 22 | document.dispatchEvent(responseEvent); 23 | }); 24 | -------------------------------------------------------------------------------- /src/lib/customhandlers/main.js: -------------------------------------------------------------------------------- 1 | import HardcodedDevice from "./hardcoded.js"; 2 | import KnownKeysDevice from "./knownkeys.js"; 3 | 4 | // add your handlers here 5 | export const CustomHandlers = { 6 | "hardcoded": { 7 | "name": "Hardcoded Example Custom Handler", 8 | "description": "Simple custom handler for testing. Values must be changed by directly editing the source. Don't use this unless you're testing the extension.", 9 | "handler": HardcodedDevice, 10 | // "for": "widevine" // or "playready", omit for both 11 | "disabled": true 12 | }, 13 | "knownkeys": { 14 | "name": "Known or Manual Keys", 15 | "description": "Play videos using keys from the saved logs, or keys entered manually, without actually performing a license exchange. Some services may reject this method.", 16 | "handler": KnownKeysDevice, 17 | "for": "widevine" 18 | } 19 | }; -------------------------------------------------------------------------------- /src/pages/picker/filePicker.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Choose a device file 5 | 6 | 7 | 8 | 9 | 10 |

Choose a device file or drop it here to import

11 | 12 | 13 |







14 | 18 |

Or download and import from a remote URL:

19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vineless", 3 | "version": "2.2", 4 | "description": "Play protected contents without a real CDM", 5 | "main": "build.js", 6 | "scripts": { 7 | "build": "npx rollup -c && node build.js" 8 | }, 9 | "keywords": [ 10 | "chrome-extension", 11 | "firefox-addon", 12 | "drm", 13 | "widevine", 14 | "playready", 15 | "encrypted-media" 16 | ], 17 | "author": "Ingan121", 18 | "license": "GPL-3.0-only", 19 | "dependencies": { 20 | "@noble/curves": "^1.9.7", 21 | "@xmldom/xmldom": "^0.8.11", 22 | "forge": "^2.3.0", 23 | "protobufjs": "^7.5.4" 24 | }, 25 | "devDependencies": { 26 | "@rollup/plugin-commonjs": "^28.0.6", 27 | "@rollup/plugin-node-resolve": "^16.0.2", 28 | "archiver": "^7.0.1", 29 | "protobufjs-cli": "^1.1.3", 30 | "rollup": "^2.79.2", 31 | "rollup-plugin-terser": "^7.0.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/pages/errview/errview.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Vineless Error 5 | 6 | 7 | 8 | 9 | 10 |

Error

11 |

Loading error details...

12 | 13 | 14 |

To open the extension DevTools, click the "Open extension details page" button, then click the "service worker" link.

15 |

If you think this is an issue with Vineless, not with your device file nor the remote API server, please report it on the GitHub issues page.

16 | 17 | 18 | -------------------------------------------------------------------------------- /src/lib/playready/device.js: -------------------------------------------------------------------------------- 1 | import { BinaryReader } from "./utils.js"; 2 | 3 | export class PlayReadyDevice { 4 | constructor(bytes) { 5 | this._reader = new BinaryReader(bytes); 6 | 7 | this._reader.readBytes(3); 8 | this.version = this._reader.readUint8(); 9 | switch (this.version) { 10 | case 2: 11 | this.group_certificate_len = this._reader.readUint32(); 12 | this.group_certificate = this._reader.readBytes(this.group_certificate_len); 13 | this.encryption_key = this._reader.readBytes(96); 14 | this.signing_key = this._reader.readBytes(96); 15 | break; 16 | case 3: 17 | this.group_key = this._reader.readBytes(96); 18 | this.encryption_key = this._reader.readBytes(96); 19 | this.signing_key = this._reader.readBytes(96); 20 | this.group_certificate_len = this._reader.readUint32(); 21 | this.group_certificate = this._reader.readBytes(this.group_certificate_len); 22 | break; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import { terser } from 'rollup-plugin-terser'; 4 | 5 | export default [ 6 | { 7 | input: 'build-scripts/forge-entry.js', 8 | output: { 9 | file: 'src/lib/forge.min.js', 10 | format: 'es' 11 | }, 12 | plugins: [nodeResolve({ browser: true }), commonjs(), terser()] 13 | }, 14 | { 15 | input: 'build-scripts/protobuf-entry.js', 16 | output: { 17 | file: 'src/lib/widevine/protobuf.min.js', 18 | format: 'es' 19 | }, 20 | plugins: [nodeResolve(), commonjs(), terser()] 21 | }, 22 | { 23 | input: 'build-scripts/xmldom-entry.js', 24 | output: { 25 | file: 'src/lib/playready/xmldom.min.js', 26 | format: 'es' 27 | }, 28 | plugins: [nodeResolve(), commonjs(), terser()] 29 | }, 30 | { 31 | input: 'build-scripts/noble-curves-entry.js', 32 | output: { 33 | file: 'src/lib/playready/noble-curves.min.js', 34 | format: 'es' 35 | }, 36 | plugins: [nodeResolve({ browser: true }), commonjs(), terser()] 37 | } 38 | ]; -------------------------------------------------------------------------------- /src/lib/playready/elgamal.js: -------------------------------------------------------------------------------- 1 | import { p256 } from './noble-curves.min.js'; 2 | import { EccKey } from './ecc_key.js'; 3 | 4 | export class ElGamal { 5 | static encrypt(affineMessagePoint, affinePublicKey) { 6 | const messagePoint = new p256.ProjectivePoint(affineMessagePoint.x, affineMessagePoint.y, 1n); 7 | const publicKey = new p256.ProjectivePoint(affinePublicKey.x, affinePublicKey.y, 1n); 8 | const ephemeralKey = EccKey.randomScalar(); 9 | 10 | const point1 = p256.ProjectivePoint.BASE.multiply(ephemeralKey); 11 | const sharedSecret = publicKey.multiply(ephemeralKey); 12 | const point2 = messagePoint.add(sharedSecret); 13 | 14 | return { 15 | point1: point1.toAffine(), 16 | point2: point2.toAffine() 17 | }; 18 | } 19 | 20 | static decrypt({ point1, point2 }, privateKey) { 21 | const projectivePoint1 = new p256.ProjectivePoint(point1.x, point1.y, 1n); 22 | const projectivePoint2 = new p256.ProjectivePoint(point2.x, point2.y, 1n); 23 | 24 | const sharedSecret = projectivePoint1.multiply(privateKey); 25 | return projectivePoint2.subtract(sharedSecret).toAffine(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/pages/errview/errview.js: -------------------------------------------------------------------------------- 1 | const url = new URL(window.location.href); 2 | const errorTitle = url.searchParams.get("title") || "Unknown Error"; 3 | const errorMessage = url.searchParams.get("message") || "Failed to load error details."; 4 | 5 | document.getElementById("title").textContent = errorTitle; 6 | document.getElementById("message").textContent = errorMessage; 7 | 8 | const openErrorsBtn = document.getElementById("openErrors"); 9 | const openExtInfoBtn = document.getElementById("openExtInfo"); 10 | if (typeof browser === "undefined") { 11 | openErrorsBtn.addEventListener("click", () => { 12 | chrome.tabs.create({ url: "about:extensions/?errors=" + chrome.runtime.id }); 13 | window.close(); 14 | }); 15 | openExtInfoBtn.addEventListener("click", () => { 16 | chrome.tabs.create({ url: "about:extensions/?id=" + chrome.runtime.id }); 17 | }); 18 | } else { 19 | // No such page in Firefox 20 | openErrorsBtn.style.display = "none"; 21 | // Firefox denies opening about:debugging 22 | openExtInfoBtn.style.display = "none"; 23 | document.getElementById("guide").textContent = "To open the extension DevTools, navigate to about:debugging, and click This Firefox (or the name of the Firefox fork you are using). Then find the Vineless extension in the list, and click the \"Inspect\" button."; 24 | } -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Vineless", 4 | "version": "2.2", 5 | "version_name": "2.2 Pre-release", 6 | "description": "Play protected contents without a real CDM", 7 | "author": "Ingan121", 8 | "homepage_url": "https://github.com/Ingan121/Vineless", 9 | "permissions": [ 10 | "activeTab", 11 | "clipboardWrite", 12 | "tabs", 13 | "scripting", 14 | "storage", 15 | "notifications", 16 | "unlimitedStorage", 17 | "webRequest", 18 | "webRequestBlocking", 19 | "webNavigation" 20 | ], 21 | "host_permissions": [ 22 | "*://*/*" 23 | ], 24 | "action": { 25 | "default_popup": "pages/panel/panel.html", 26 | "default_icon": { 27 | "128": "images/icon.png" 28 | } 29 | }, 30 | "icons": { 31 | "128": "images/icon.png" 32 | }, 33 | "background": { 34 | "scripts": ["background.js"], 35 | "service_worker": "background.js", 36 | "type": "module" 37 | }, 38 | "content_scripts": [ 39 | { 40 | "matches": ["", "file://*/*"], 41 | "js": ["message_proxy.js"], 42 | "run_at": "document_start", 43 | "world": "ISOLATED", 44 | "all_frames": true, 45 | "match_about_blank": true 46 | } 47 | ], 48 | "browser_specific_settings": { 49 | "gecko": { 50 | "id": "vineless@ingan121.com", 51 | "strict_min_version": "58.0" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/lib/playready/ecc_key.js: -------------------------------------------------------------------------------- 1 | import { p256, utils } from './noble-curves.min.js'; 2 | import { Crypto } from "./crypto.js"; 3 | import {Utils} from "./utils.js"; 4 | 5 | export class EccKey { 6 | constructor(privateKey, publicKey) { 7 | this.privateKey = privateKey; 8 | this.publicKey = publicKey; 9 | } 10 | 11 | static randomScalar() { 12 | const randomBytes = Crypto.randomBytes(32); 13 | return utils.bytesToNumberBE(randomBytes) % p256.CURVE.n; 14 | } 15 | 16 | static generate() { 17 | const privateKey = EccKey.randomScalar(); 18 | const publicKey = p256.ProjectivePoint.BASE.multiply(privateKey).toAffine(); 19 | return new EccKey(privateKey, publicKey); 20 | } 21 | 22 | static construct(privateKey) { 23 | const publicKey = p256.ProjectivePoint.BASE.multiply(privateKey).toAffine(); 24 | return new EccKey(privateKey, publicKey); 25 | } 26 | 27 | static loads(bytes) { 28 | const privateBytes = bytes.subarray(0, 32); 29 | return EccKey.construct(utils.bytesToNumberBE(privateBytes)); 30 | } 31 | 32 | dumps() { 33 | return new Uint8Array([ 34 | ...this.privateBytes(), 35 | ...this.publicBytes() 36 | ]); 37 | } 38 | 39 | privateBytes() { 40 | return utils.numberToBytesBE(this.privateKey, 32); 41 | } 42 | 43 | publicBytes() { 44 | return new Uint8Array([ 45 | ...utils.numberToBytesBE(this.publicKey.x, 32), 46 | ...utils.numberToBytesBE(this.publicKey.y, 32) 47 | ]); 48 | } 49 | 50 | privateSha256Digest() { 51 | return Crypto.sha256(Utils.bytesToString(this.publicBytes())); 52 | } 53 | } -------------------------------------------------------------------------------- /devices/sg_sample_prproxy_api.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "PLAYREADY", 3 | "security_level": "3000", 4 | "host": "http://127.0.0.1:5000", 5 | "secret": "Ingan121_80c7b890f55b5755faa12fc005b8b333", 6 | "device_name": "NVIDIA", 7 | "name_override": "SuperGeneric Sample for PlayReadyProxy-API", 8 | "sg_api_conf": { 9 | "headers": { 10 | "Content-Type": "application/json", 11 | "X-API-KEY": "{secret}" 12 | }, 13 | "generateChallenge": [ 14 | { 15 | "method": "GET", 16 | "url": "/api/playready/{device_name}/open", 17 | "sessionIdResKeyName": "responseData.session_id" 18 | }, 19 | { 20 | "method": "POST", 21 | "url": "/api/playready/{device_name}/get_challenge", 22 | "bodyObj": { 23 | "privacy_mode": true 24 | }, 25 | "sessionIdKeyName": "session_id", 26 | "psshKeyName": "pssh", 27 | "challengeKeyName": "responseData.challenge_b64", 28 | "bundleInKeyMessage": true 29 | } 30 | ], 31 | "parseLicense": [ 32 | { 33 | "method": "POST", 34 | "url": "/api/playready/{device_name}/get_keys", 35 | "sessionIdKeyName": "session_id", 36 | "licenseKeyName": "license_b64", 37 | "contentKeysKeyName": "responseData.keys" 38 | }, 39 | { 40 | "method": "GET", 41 | "url": "/api/playready/{device_name}/close/%s" 42 | } 43 | ], 44 | "keyParseRules": { 45 | "keyKeyName": "key", 46 | "kidKeyName": "key_id" 47 | }, 48 | "messageKey": "responseData.message" 49 | } 50 | } -------------------------------------------------------------------------------- /devices/sg_sample_cdrm_pr.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "PLAYREADY", 3 | "device_name": "public", 4 | "host": "https://cdrm-project.com/remotecdm/playready", 5 | "name_override": "SuperGeneric Sample for CDRM-Project PlayReady", 6 | "security_level": 3000, 7 | "sg_api_conf": { 8 | "headers": { 9 | "Content-Type": "application/json" 10 | }, 11 | "generateChallenge": [ 12 | { 13 | "method": "GET", 14 | "url": "/{device_name}/open", 15 | "sessionIdResKeyName": "data.session_id" 16 | }, 17 | { 18 | "method": "POST", 19 | "url": "/{device_name}/get_license_challenge", 20 | "bodyObj": { 21 | "privacy_mode": true 22 | }, 23 | "sessionIdKeyName": "session_id", 24 | "psshKeyName": "init_data", 25 | "challengeKeyName": "data.challenge", 26 | "encodeB64": true, 27 | "bundleInKeyMessage": true 28 | } 29 | ], 30 | "parseLicense": [ 31 | { 32 | "method": "POST", 33 | "url": "/{device_name}/parse_license", 34 | "sessionIdKeyName": "session_id", 35 | "licenseKeyName": "license_message" 36 | }, 37 | { 38 | "method": "POST", 39 | "url": "/{device_name}/get_keys", 40 | "sessionIdKeyName": "session_id", 41 | "contentKeysKeyName": "data.keys" 42 | }, 43 | { 44 | "method": "GET", 45 | "url": "/{device_name}/close/%s" 46 | } 47 | ], 48 | "keyParseRules": { 49 | "keyKeyName": "key", 50 | "kidKeyName": "key_id" 51 | }, 52 | "messageKey": "message" 53 | } 54 | } -------------------------------------------------------------------------------- /devices/kxremote_sg_wv_trial.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "WIDEVINE", 3 | "device_name": "DecryptLabs KeyXtractor", 4 | "name_override": "DecryptLabs KeyXtractor (Trial till 29 Aug - EXPIRED)", 5 | "device_type": "ANDROID", 6 | "host": "https://keyxtractor.decryptlabs.com", 7 | "security_level": 1, 8 | "system_id": 12063, 9 | "secret": "decrypt_labs_special_ultimate", 10 | "_comment": "Only works on Firefox-based browsers due to the origin header replacement not working in Chromium MV3. Use a CORS proxy that strips the origin header for Chromium-based browsers.", 11 | "sg_api_conf": { 12 | "headers": { 13 | "Content-Type": "application/json", 14 | "decrypt-labs-api-key": "{secret}" 15 | }, 16 | "overrideHeaders": { 17 | "urls": ["*://keyxtractor.decryptlabs.com/*"], 18 | "headers": { 19 | "Origin": "null" 20 | } 21 | }, 22 | "generateChallenge": { 23 | "url": "/get-request", 24 | "bodyObj": { 25 | "scheme": "L1", 26 | "service": "generic", 27 | "get_cached_keys_if_exists": false 28 | }, 29 | "sessionIdKeyName": "session_id", 30 | "psshKeyName": "init_data", 31 | "serverCertKeyName": "service_certificate", 32 | "sessionIdResKeyName": "session_id", 33 | "challengeKeyName": "challenge" 34 | }, 35 | "parseLicense": { 36 | "url": "/decrypt-response", 37 | "bodyObj": { 38 | "scheme": "L1" 39 | }, 40 | "sessionIdKeyName": "session_id", 41 | "psshKeyName": "init_data", 42 | "challengeKeyName": "license_request", 43 | "licenseKeyName": "license_response", 44 | "contentKeysKeyName": "keys" 45 | }, 46 | "keyParseRules": { 47 | "regex": { 48 | "data": "--key ([0-9a-fA-F]+):([0-9a-fA-F]+)" 49 | } 50 | }, 51 | "messageKey": "Error" 52 | } 53 | } -------------------------------------------------------------------------------- /devices/sg_sample_cdrm_wv.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "WIDEVINE", 3 | "device_name": "public", 4 | "device_type": "ANDROID", 5 | "host": "https://cdrm-project.com/remotecdm/widevine", 6 | "name_override": "SuperGeneric Sample for CDRM-Project Widevine", 7 | "security_level": 3, 8 | "system_id": 22590, 9 | "sg_api_conf": { 10 | "headers": { 11 | "Content-Type": "application/json" 12 | }, 13 | "generateChallenge": [ 14 | { 15 | "method": "GET", 16 | "url": "/{device_name}/open", 17 | "sessionIdResKeyName": "data.session_id" 18 | }, 19 | { 20 | "method": "POST", 21 | "url": "/{device_name}/set_service_certificate", 22 | "sessionIdKeyName": "session_id", 23 | "serverCertKeyName": "certificate", 24 | "serverCertOnly": true 25 | }, 26 | { 27 | "method": "POST", 28 | "url": "/{device_name}/get_license_challenge/AUTOMATIC", 29 | "bodyObj": { 30 | "privacy_mode": true 31 | }, 32 | "sessionIdKeyName": "session_id", 33 | "psshKeyName": "init_data", 34 | "challengeKeyName": "data.challenge_b64" 35 | } 36 | ], 37 | "parseLicense": [ 38 | { 39 | "method": "POST", 40 | "url": "/{device_name}/parse_license", 41 | "sessionIdKeyName": "session_id", 42 | "licenseKeyName": "license_message" 43 | }, 44 | { 45 | "method": "POST", 46 | "url": "/{device_name}/get_keys/CONTENT", 47 | "sessionIdKeyName": "session_id", 48 | "contentKeysKeyName": "data.keys" 49 | }, 50 | { 51 | "method": "GET", 52 | "url": "/{device_name}/close/%s" 53 | } 54 | ], 55 | "keyParseRules": { 56 | "keyKeyName": "key", 57 | "kidKeyName": "key_id" 58 | }, 59 | "messageKey": "message" 60 | } 61 | } -------------------------------------------------------------------------------- /src/pages/picker/filePicker.js: -------------------------------------------------------------------------------- 1 | import { SettingsManager } from "../../util.js"; 2 | 3 | const type = new URL(location.href).searchParams.get('type'); 4 | const fileInput = document.getElementById('fileInput'); 5 | fileInput.accept = type === "remote" ? ".json" : "." + type; 6 | 7 | async function importDevice(file) { 8 | const importFunctions = { 9 | "wvd": SettingsManager.importDevice, 10 | "prd": SettingsManager.importPRDevice, 11 | "remote": SettingsManager.loadRemoteCDM 12 | }; 13 | // Always use this order, as prd validation is not strict 14 | const order = ['remote', 'wvd', 'prd']; 15 | 16 | for (const type of order) { 17 | try { 18 | await importFunctions[type](file); 19 | console.log(`Imported ${file.name} as ${type}`); 20 | return true; 21 | } catch (e) { 22 | console.warn(`Failed to import ${file.name} as ${type}:`, e); 23 | // go on 24 | } 25 | } 26 | // real failure 27 | return false; 28 | } 29 | 30 | fileInput.addEventListener('change', async (event) => { 31 | for (const file of event.target.files) { 32 | if (!await importDevice(file)) { 33 | window.resizeTo(800, 600); 34 | alert("Failed to import device file: " + file.name); 35 | } 36 | } 37 | window.close(); 38 | }); 39 | 40 | document.addEventListener("drop", async (event) => { 41 | event.preventDefault(); 42 | for (const file of event.dataTransfer.files) { 43 | if (!await importDevice(file)) { 44 | window.resizeTo(800, 600); 45 | alert("Failed to import device file: " + file.name); 46 | } 47 | } 48 | window.close(); 49 | }); 50 | window.addEventListener("dragover", e => e.preventDefault()); 51 | 52 | document.getElementById('urlImport').addEventListener('click', async () => { 53 | try { 54 | const url = document.getElementById('urlInput').value; 55 | const res = await fetch(url); 56 | const blob = await res.blob(); 57 | blob.name = decodeURIComponent(url.split('/').pop()); 58 | if (!await importDevice(blob)) { 59 | window.resizeTo(800, 600); 60 | alert("Invalid device file!"); 61 | } 62 | window.close(); 63 | } catch (e) { 64 | console.error(e); 65 | alert("Failed to import!\n" + e.stack); 66 | } 67 | }); -------------------------------------------------------------------------------- /src/lib/playready/crypto.js: -------------------------------------------------------------------------------- 1 | import { Utils } from "./utils.js"; 2 | import { p256, utils } from './noble-curves.min.js'; 3 | import { ElGamal } from "./elgamal.js"; 4 | import forge from "../forge.min.js"; 5 | 6 | export class Crypto { 7 | static ecc256decrypt(private_key, ciphertext) { 8 | const decrypted = ElGamal.decrypt( 9 | { 10 | point1: { 11 | x: utils.bytesToNumberBE(ciphertext.subarray(0, 32)), 12 | y: utils.bytesToNumberBE(ciphertext.subarray(32, 64)) 13 | }, 14 | point2: { 15 | x: utils.bytesToNumberBE(ciphertext.subarray(64, 96)), 16 | y: utils.bytesToNumberBE(ciphertext.subarray(96, 128)) 17 | } 18 | }, 19 | private_key 20 | ); 21 | 22 | return utils.numberToBytesBE(decrypted.x, 32); 23 | } 24 | 25 | static ecc256Sign(private_key, data) { 26 | return p256.sign( 27 | Crypto.sha256(data), 28 | private_key 29 | ); 30 | } 31 | 32 | static aesCbcEncrypt(key, iv, data) { 33 | const cipher = forge.cipher.createCipher( 34 | 'AES-CBC', 35 | forge.util.createBuffer(key, 'raw') 36 | ); 37 | 38 | cipher.start({ 39 | iv: forge.util.createBuffer(iv, 'raw').getBytes() 40 | }); 41 | 42 | cipher.update(forge.util.createBuffer(data, 'raw')); 43 | cipher.finish(); 44 | 45 | return Utils.stringToBytes(cipher.output.getBytes()); 46 | } 47 | 48 | static aesEcbEncrypt(key, data) { 49 | const cipher = forge.cipher.createCipher( 50 | 'AES-ECB', 51 | forge.util.createBuffer(key, 'raw') 52 | ); 53 | 54 | cipher.mode.pad = function(){}; 55 | cipher.mode.unpad = function(){}; 56 | 57 | cipher.start(); 58 | cipher.update(forge.util.createBuffer(data, 'raw')); 59 | cipher.finish(); 60 | 61 | return Utils.stringToBytes(cipher.output.getBytes()); 62 | } 63 | 64 | static sha256(data) { 65 | const md = forge.md.sha256.create(); 66 | md.update(data); 67 | return Utils.stringToBytes(md.digest().getBytes()); 68 | } 69 | 70 | static randomBytes(size) { 71 | const randomBytes = new Uint8Array(size); 72 | crypto.getRandomValues(randomBytes); 73 | return randomBytes; 74 | } 75 | } -------------------------------------------------------------------------------- /src/lib/playready/utils.js: -------------------------------------------------------------------------------- 1 | export class Utils { 2 | static bytesToString(bytes) { 3 | return String.fromCharCode.apply(null, bytes); 4 | } 5 | 6 | static stringToBytes(string) { 7 | return Uint8Array.from(string.split("").map(x => x.charCodeAt())); 8 | } 9 | 10 | static tryGetUtf16Le(bytes) { 11 | if (bytes.length % 2 !== 0) { 12 | return null; 13 | } 14 | 15 | for (let i = 1; i < bytes.length; i += 2) { 16 | if (bytes[i] !== 0) { 17 | return null; 18 | } 19 | } 20 | 21 | try { 22 | const decoder = new TextDecoder('utf-16le', { fatal: true }); 23 | return decoder.decode(bytes); 24 | } catch (e) { 25 | return null; 26 | } 27 | } 28 | 29 | static compareArrays(arr1, arr2) { 30 | if (arr1.length !== arr2.length) 31 | return false; 32 | return Array.from(arr1).every((value, index) => value === arr2[index]); 33 | } 34 | 35 | static base64ToBytes(base64_string){ 36 | return Uint8Array.from(atob(base64_string), c => c.charCodeAt(0)); 37 | } 38 | 39 | static bytesToBase64(uint8array) { 40 | return btoa(String.fromCharCode.apply(null, uint8array)); 41 | } 42 | 43 | static xorArrays(arr1, arr2) { 44 | return new Uint8Array(arr1.map((byte, i) => byte ^ arr2[i])); 45 | } 46 | } 47 | 48 | export class BinaryReader { 49 | constructor(data) { 50 | this.offset = 0; 51 | this.length = data.length; 52 | this._raw_bytes = new Uint8Array(data); 53 | this._data_view = new DataView(data.buffer, data.byteOffset, data.byteLength); 54 | } 55 | 56 | readUint8(){ 57 | return this._data_view.getUint8(this.offset++); 58 | } 59 | 60 | readUint16(little){ 61 | const result = this._data_view.getUint16(this.offset, little); 62 | this.offset += 2; 63 | return result; 64 | } 65 | 66 | readUint32(little){ 67 | const result = this._data_view.getUint32(this.offset, little); 68 | this.offset += 4; 69 | return result; 70 | } 71 | 72 | readBytes(size){ 73 | const result = this._raw_bytes.subarray(this.offset, this.offset + size); 74 | this.offset += size; 75 | return result; 76 | } 77 | 78 | reset() { 79 | this._data_view = new DataView(this._raw_bytes.buffer); 80 | this.offset = 0; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/lib/widevine/main.js: -------------------------------------------------------------------------------- 1 | import { Session } from "./license.js"; 2 | import { WidevineDevice } from "./device.js"; 3 | import { 4 | DeviceManager, 5 | base64toUint8Array, 6 | uint8ArrayToBase64, 7 | compareUint8Arrays 8 | } from "../../util.js"; 9 | import { license_protocol } from "./license_protocol.js"; 10 | 11 | const { LicenseType } = license_protocol; 12 | 13 | export class WidevineLocal { 14 | constructor(host, keySystem, sessionId, tab) { 15 | this.host = host; 16 | } 17 | 18 | async generateChallenge(pssh, extra) { 19 | const { serverCert } = extra; 20 | 21 | if (!pssh) { 22 | throw new Error("No PSSH data in challenge"); 23 | } 24 | 25 | const selected_device_name = await DeviceManager.getSelectedWidevineDevice(this.host); 26 | if (!selected_device_name) { 27 | throw new Error("No Widevine device selected"); 28 | } 29 | 30 | const device_b64 = await DeviceManager.loadWidevineDevice(selected_device_name); 31 | const widevine_device = new WidevineDevice(base64toUint8Array(device_b64).buffer); 32 | 33 | const psshBytes = base64toUint8Array(pssh); 34 | const PSSH_MAGIC = new Uint8Array([0x70, 0x73, 0x73, 0x68]); 35 | let initDataType = "cenc"; 36 | if (!compareUint8Arrays(psshBytes.subarray(4, 8), PSSH_MAGIC)) { 37 | initDataType = "webm"; 38 | } 39 | 40 | const private_key = `-----BEGIN RSA PRIVATE KEY-----${uint8ArrayToBase64(widevine_device.private_key)}-----END RSA PRIVATE KEY-----`; 41 | this.session = new Session( 42 | { 43 | privateKey: private_key, 44 | identifierBlob: widevine_device.client_id_bytes 45 | }, 46 | psshBytes, 47 | initDataType 48 | ); 49 | 50 | if (serverCert) { 51 | await this.session.setServiceCertificate(base64toUint8Array(serverCert)); 52 | console.log("Set service certificate", this.session._serviceCertificate); 53 | } 54 | 55 | const [challenge] = this.session.createLicenseRequest(LicenseType.AUTOMATIC, widevine_device.type === 2); 56 | 57 | return uint8ArrayToBase64(challenge); 58 | } 59 | 60 | async parseLicense(license) { 61 | const keys = await this.session.parseLicense(base64toUint8Array(license)); 62 | const pssh = this.session.getPSSH(); 63 | 64 | return { 65 | type: "WIDEVINE", 66 | pssh: pssh, 67 | keys: keys 68 | }; 69 | } 70 | } -------------------------------------------------------------------------------- /src/lib/widevine/device.js: -------------------------------------------------------------------------------- 1 | import { license_protocol } from "./license_protocol.js"; 2 | const { ClientIdentification, SignedDrmCertificate, DrmCertificate } = license_protocol; 3 | 4 | export class Crc32 { 5 | constructor() { 6 | this.crc_table = this.setupTable(); 7 | } 8 | 9 | setupTable() { 10 | let c; 11 | const crcTable = []; 12 | for (let n = 0; n < 256; n++) { 13 | c = n; 14 | for (let k = 0; k < 8; k++) { 15 | c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); 16 | } 17 | crcTable[n] = c; 18 | } 19 | return crcTable; 20 | } 21 | 22 | crc32(uint8Array) { 23 | let crc = 0 ^ (-1); 24 | for (let i = 0; i < uint8Array.length; i++) { 25 | crc = (crc >>> 8) ^ this.crc_table[(crc ^ uint8Array[i]) & 0xFF]; 26 | } 27 | return (crc ^ (-1)) >>> 0; 28 | } 29 | } 30 | 31 | export class WidevineDevice { 32 | constructor(bytes) { 33 | this._raw_bytes = new Uint8Array(bytes); 34 | this._data_view = new DataView(bytes); 35 | 36 | this.version = this._data_view.getUint8(3); 37 | this.type = this._data_view.getUint8(4); 38 | this.security_level = this._data_view.getUint8(5); 39 | this.flags = this._data_view.getUint8(6); 40 | 41 | this.private_key_len = this._data_view.getUint16(7); 42 | this.private_key = this._raw_bytes.subarray(9, 9 + this.private_key_len); 43 | 44 | this.client_id_len = this._data_view.getUint16(9 + this.private_key_len); 45 | this.client_id_bytes = this._raw_bytes.subarray(11 + this.private_key_len, 11 + this.private_key_len + this.client_id_len); 46 | this.client_id = ClientIdentification.decode(this.client_id_bytes); 47 | } 48 | 49 | getName() { 50 | const client_info = Object.fromEntries(this.client_id.clientInfo.map(item => [item.name, item.value])) 51 | const type = this.type === 1 ? "CHROME" : `L${this.security_level}` 52 | 53 | const root_signed_cert = SignedDrmCertificate.decode(this.client_id.token); 54 | const root_cert = DrmCertificate.decode(root_signed_cert.drmCertificate); 55 | 56 | let name = `[${type}]`; 57 | if (client_info["company_name"]) 58 | name += ` ${client_info["company_name"]}`; 59 | if (client_info["model_name"]) 60 | name += ` ${client_info["model_name"]}`; 61 | if (client_info["product_name"]) 62 | name += ` ${client_info["product_name"]}`; 63 | if (root_cert.systemId) 64 | name += ` (${root_cert.systemId})`; 65 | 66 | const crc32 = new Crc32(); 67 | name += ` [${crc32.crc32(this._raw_bytes).toString(16)}]`; 68 | 69 | return name; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 41 | 55 | 56 | 58 | 62 | 67 | 72 | 77 | 78 | 80 | 81 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /src/lib/playready/main.js: -------------------------------------------------------------------------------- 1 | import { Cdm } from './cdm.js'; 2 | import { PlayReadyDevice } from "./device.js"; 3 | import { Utils } from "./utils.js"; 4 | import { utils } from "./noble-curves.min.js"; 5 | import { 6 | PRDeviceManager, 7 | base64toUint8Array, 8 | uint8ArrayToBase64 9 | } from '../../util.js'; 10 | 11 | export class PlayReadyLocal { 12 | constructor(host, keySystem, sessionId, tab) { 13 | this.host = host; 14 | } 15 | 16 | async generateChallenge(pssh, extra) { 17 | if (!pssh) { 18 | throw new Error("No PSSH data in challenge"); 19 | } 20 | 21 | const selected_device_name = await PRDeviceManager.getSelectedPlayreadyDevice(this.host); 22 | if (!selected_device_name) { 23 | throw new Error("No PlayReady device selected"); 24 | } 25 | 26 | const device_b64 = await PRDeviceManager.loadPlayreadyDevice(selected_device_name); 27 | const playready_device = new PlayReadyDevice(Utils.base64ToBytes(device_b64)); 28 | this.cdm = Cdm.fromDevice(playready_device); 29 | 30 | const rawInitData = base64toUint8Array(pssh); 31 | const decodedInitData = new TextDecoder("utf-16le").decode(rawInitData); 32 | 33 | /* 34 | * arbitrary data could be formatted in a special way and parsing it with the spec-compliant xmldom could remove 35 | * required end tags (e.g. '') 36 | * */ 37 | this.wrmHeader = decodedInitData.match(//gm)[0]; 38 | const version = "10.0.16384.10011"; 39 | 40 | const licenseChallenge = this.cdm.getLicenseChallenge(this.wrmHeader, "", version); 41 | const newChallenge = btoa(licenseChallenge); 42 | 43 | const newXmlDoc = ` 44 | 45 | ${newChallenge} 46 | 47 | 48 | Content-Type 49 | text/xml; charset=utf-8 50 | 51 | 52 | SOAPAction 53 | "http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense" 54 | 55 | 56 | 57 | `.replace(/ |\n/g, ''); 58 | 59 | const utf8KeyMessage = new TextEncoder().encode(newXmlDoc); 60 | const newKeyMessage = new Uint8Array(utf8KeyMessage.length * 2); 61 | 62 | for (let i = 0; i < utf8KeyMessage.length; i++) { 63 | newKeyMessage[i * 2] = utf8KeyMessage[i]; 64 | newKeyMessage[i * 2 + 1] = 0; 65 | } 66 | 67 | return uint8ArrayToBase64(newKeyMessage); 68 | } 69 | 70 | async parseLicense(license) { 71 | const returned_keys = this.cdm.parseLicense(atob(license)); 72 | const keys = returned_keys.map(key => ({ k: utils.bytesToHex(key.key), kid: utils.bytesToHex(key.key_id) })); 73 | 74 | return { 75 | type: "PLAYREADY", 76 | pssh: this.wrmHeader, 77 | keys: keys 78 | }; 79 | } 80 | } -------------------------------------------------------------------------------- /src/lib/playready/xmr_license.js: -------------------------------------------------------------------------------- 1 | import { BinaryReader, Utils } from "./utils.js"; 2 | import { AES_CMAC } from "./cmac.js"; 3 | 4 | class _SignatureObject { 5 | constructor(reader) { 6 | this.signature_type = reader.readUint16(); 7 | this.signature_data_length = reader.readUint16(); 8 | this.signature_data = reader.readBytes(this.signature_data_length); 9 | } 10 | } 11 | 12 | class _AuxiliaryKey { 13 | constructor(reader) { 14 | this.location = reader.readUint32(); 15 | this.key = reader.readBytes(16); 16 | } 17 | } 18 | 19 | class _AuxiliaryKeysObject { 20 | constructor(reader) { 21 | this.count = reader.readUint16(); 22 | this.auxiliary_keys = []; 23 | for (let i = 0; i < this.count; i++) { 24 | this.auxiliary_keys.push(new _AuxiliaryKey(reader)); 25 | } 26 | } 27 | } 28 | 29 | class _ContentKeyObject { 30 | constructor(reader) { 31 | this.key_id = reader.readBytes(16); 32 | this.key_type = reader.readUint16(); 33 | this.cipher_type = reader.readUint16(); 34 | this.key_length = reader.readUint16(); 35 | this.encrypted_key = reader.readBytes(this.key_length); 36 | } 37 | } 38 | 39 | class _XmrObject { 40 | constructor(reader) { 41 | this.flags = reader.readUint16(); 42 | this.type = reader.readUint16(); 43 | this.length = reader.readUint32(); 44 | this.data = null; 45 | if (this.flags === 0 || this.flags === 1) { 46 | switch (this.type) { 47 | case 10: 48 | this.data = new _ContentKeyObject(reader); 49 | break; 50 | case 11: 51 | this.data = new _SignatureObject(reader); 52 | break; 53 | case 81: 54 | this.data = new _AuxiliaryKeysObject(reader); 55 | break; 56 | default: 57 | this.data = reader.readBytes(this.length - 8); 58 | } 59 | } 60 | } 61 | } 62 | 63 | class _XmrLicense { 64 | constructor(reader) { 65 | this.signature = reader.readBytes(4); 66 | this.xmr_version = reader.readUint32(); 67 | this.rights_id = reader.readBytes(16); 68 | this.containers = []; 69 | while (reader.length > reader.offset) { 70 | this.containers.push(new _XmrObject(reader)); 71 | } 72 | } 73 | } 74 | 75 | export class XmrLicense { 76 | constructor(reader, license_obj) { 77 | this._reader = reader; 78 | this._license_obj = license_obj; 79 | } 80 | 81 | static loads(bytes) { 82 | const reader = new BinaryReader(bytes); 83 | return new XmrLicense(reader, new _XmrLicense(reader)); 84 | } 85 | 86 | getObjects(type) { 87 | return this._license_obj.containers.filter(obj => obj.type === type); 88 | } 89 | 90 | checkSignature(integrity_key) { 91 | const signatureObject = this.getObjects(11)[0].data; 92 | const raw_data = this._reader._raw_bytes; 93 | 94 | const cmac = new AES_CMAC(integrity_key); 95 | const signatureData = raw_data.subarray(0, raw_data.length - (signatureObject.signature_data_length + 12)); 96 | const signature = cmac.calculate(signatureData); 97 | 98 | return Utils.compareArrays(signature, signatureObject.signature_data); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/lib/playready/cmac.js: -------------------------------------------------------------------------------- 1 | import forge from "../forge.min.js"; 2 | 3 | export class AES_CMAC { 4 | BLOCK_SIZE = 16; 5 | XOR_RIGHT = new Uint8Array([ 6 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 7 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x87 8 | ]); 9 | EMPTY_BLOCK_SIZE_BUFFER = new Uint8Array(this.BLOCK_SIZE); 10 | 11 | constructor(key) { 12 | if (![16, 24, 32].includes(key.length)) { 13 | throw new Error("Key size must be 128, 192, or 256 bits."); 14 | } 15 | this._key = key; 16 | } 17 | 18 | calculate(message) { 19 | this._subkeys = this._generateSubkeys(); 20 | const blockCount = this._getBlockCount(message); 21 | 22 | let x = this.EMPTY_BLOCK_SIZE_BUFFER; 23 | let y; 24 | 25 | for (let i = 0; i < blockCount - 1; i++) { 26 | const from = i * this.BLOCK_SIZE; 27 | const block = message.subarray(from, from + this.BLOCK_SIZE); 28 | y = this._xor(x, block); 29 | x = this._aes(y); 30 | } 31 | 32 | y = this._xor(x, this._getLastBlock(message)); 33 | x = this._aes(y); 34 | 35 | return x; 36 | } 37 | 38 | _generateSubkeys() { 39 | const l = this._aes(this.EMPTY_BLOCK_SIZE_BUFFER); 40 | 41 | let first = this._bitShiftLeft(l); 42 | if (l[0] & 0x80) { 43 | first = this._xor(first, this.XOR_RIGHT); 44 | } 45 | 46 | let second = this._bitShiftLeft(first); 47 | if (first[0] & 0x80) { 48 | second = this._xor(second, this.XOR_RIGHT); 49 | } 50 | 51 | return { first: first, second: second }; 52 | } 53 | 54 | _getBlockCount(message) { 55 | const blockCount = Math.ceil(message.length / this.BLOCK_SIZE); 56 | return blockCount === 0 ? 1 : blockCount; 57 | } 58 | 59 | _aes(message) { 60 | const keyBuffer = forge.util.createBuffer(this._key, 'raw'); 61 | const cipher = forge.cipher.createCipher('AES-CBC', keyBuffer); 62 | 63 | const iv = forge.util.createBuffer(new Uint8Array(16), 'raw'); 64 | cipher.start({ 65 | iv: iv.getBytes() 66 | }); 67 | 68 | cipher.update(forge.util.createBuffer(message)); 69 | cipher.finish(); 70 | 71 | const outputBuffer = cipher.output; 72 | return new Uint8Array(outputBuffer.getBytes().slice(0, 16).split('').map(c => c.charCodeAt(0))); 73 | } 74 | 75 | _getLastBlock(message) { 76 | const blockCount = this._getBlockCount(message); 77 | const paddedBlock = this._padding(message, blockCount - 1); 78 | 79 | let complete = false; 80 | if (message.length > 0) { 81 | complete = message.length % this.BLOCK_SIZE === 0; 82 | } 83 | 84 | const key = complete ? this._subkeys.first : this._subkeys.second; 85 | return this._xor(paddedBlock, key); 86 | } 87 | 88 | _padding(message, blockIndex) { 89 | const block = new Uint8Array(this.BLOCK_SIZE); 90 | 91 | const from = blockIndex * this.BLOCK_SIZE; 92 | const slice = message.subarray(from, from + this.BLOCK_SIZE); 93 | block.set(slice); 94 | 95 | if (slice.length !== this.BLOCK_SIZE) { 96 | block[slice.length] = 0x80; 97 | } 98 | 99 | return block; 100 | } 101 | 102 | _bitShiftLeft(input) { 103 | const output = new Uint8Array(input.length); 104 | let overflow = 0; 105 | for (let i = input.length - 1; i >= 0; i--) { 106 | output[i] = (input[i] << 1) | overflow; 107 | overflow = input[i] & 0x80 ? 1 : 0; 108 | } 109 | return output; 110 | } 111 | 112 | _xor(a, b) { 113 | const length = Math.min(a.length, b.length); 114 | const output = new Uint8Array(length); 115 | for (let i = 0; i < length; i++) { 116 | output[i] = a[i] ^ b[i]; 117 | } 118 | return output; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/lib/widevine/cmac.js: -------------------------------------------------------------------------------- 1 | import forge from "../forge.min.js"; 2 | 3 | export class AES_CMAC { 4 | BLOCK_SIZE = 16; 5 | XOR_RIGHT = new Uint8Array([ 6 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 7 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x87 8 | ]); 9 | EMPTY_BLOCK_SIZE_BUFFER = new Uint8Array(this.BLOCK_SIZE); 10 | 11 | constructor(key) { 12 | if (![16, 24, 32].includes(key.length)) { 13 | throw new Error("Key size must be 128, 192, or 256 bits."); 14 | } 15 | this._key = key; 16 | } 17 | 18 | async calculate(message) { 19 | this._subkeys = await this._generateSubkeys(); 20 | const blockCount = this._getBlockCount(message); 21 | 22 | let x = this.EMPTY_BLOCK_SIZE_BUFFER; 23 | let y; 24 | 25 | for (let i = 0; i < blockCount - 1; i++) { 26 | const from = i * this.BLOCK_SIZE; 27 | const block = message.subarray(from, from + this.BLOCK_SIZE); 28 | y = this._xor(x, block); 29 | x = await this._aes(y); 30 | } 31 | 32 | y = this._xor(x, this._getLastBlock(message)); 33 | x = await this._aes(y); 34 | 35 | return x; 36 | } 37 | 38 | async _generateSubkeys() { 39 | const l = await this._aes(this.EMPTY_BLOCK_SIZE_BUFFER); 40 | 41 | let first = this._bitShiftLeft(l); 42 | if (l[0] & 0x80) { 43 | first = this._xor(first, this.XOR_RIGHT); 44 | } 45 | 46 | let second = this._bitShiftLeft(first); 47 | if (first[0] & 0x80) { 48 | second = this._xor(second, this.XOR_RIGHT); 49 | } 50 | 51 | return { first: first, second: second }; 52 | } 53 | 54 | _getBlockCount(message) { 55 | const blockCount = Math.ceil(message.length / this.BLOCK_SIZE); 56 | return blockCount === 0 ? 1 : blockCount; 57 | } 58 | 59 | async _aes(message) { 60 | const keyBuffer = forge.util.createBuffer(this._key, 'raw'); 61 | const cipher = forge.cipher.createCipher('AES-CBC', keyBuffer); 62 | 63 | const iv = forge.util.createBuffer(new Uint8Array(16), 'raw'); 64 | cipher.start({ 65 | iv: iv.getBytes() 66 | }); 67 | 68 | cipher.update(forge.util.createBuffer(message)); 69 | cipher.finish(); 70 | 71 | const outputBuffer = cipher.output; 72 | return new Uint8Array(outputBuffer.getBytes().slice(0, 16).split('').map(c => c.charCodeAt(0))); 73 | } 74 | 75 | _getLastBlock(message) { 76 | const blockCount = this._getBlockCount(message); 77 | const paddedBlock = this._padding(message, blockCount - 1); 78 | 79 | let complete = false; 80 | if (message.length > 0) { 81 | complete = message.length % this.BLOCK_SIZE === 0; 82 | } 83 | 84 | const key = complete ? this._subkeys.first : this._subkeys.second; 85 | return this._xor(paddedBlock, key); 86 | } 87 | 88 | _padding(message, blockIndex) { 89 | const block = new Uint8Array(this.BLOCK_SIZE); 90 | 91 | const from = blockIndex * this.BLOCK_SIZE; 92 | const slice = message.subarray(from, from + this.BLOCK_SIZE); 93 | block.set(slice); 94 | 95 | if (slice.length !== this.BLOCK_SIZE) { 96 | block[slice.length] = 0x80; 97 | } 98 | 99 | return block; 100 | } 101 | 102 | _bitShiftLeft(input) { 103 | const output = new Uint8Array(input.length); 104 | let overflow = 0; 105 | for (let i = input.length - 1; i >= 0; i--) { 106 | output[i] = (input[i] << 1) | overflow; 107 | overflow = input[i] & 0x80 ? 1 : 0; 108 | } 109 | return output; 110 | } 111 | 112 | _xor(a, b) { 113 | const length = Math.min(a.length, b.length); 114 | const output = new Uint8Array(length); 115 | for (let i = 0; i < length; i++) { 116 | output[i] = a[i] ^ b[i]; 117 | } 118 | return output; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/lib/customhandlers/hardcoded.js: -------------------------------------------------------------------------------- 1 | // Stupid example custom handler that just hardcodes everything 2 | // Don't use it, please. 3 | 4 | // Default values are for https://reference.dashif.org/dash.js/latest/samples/drm/widevine.html 5 | 6 | export default class HardcodedDevice { 7 | constructor(host, keySystem, sessionId, tab) { 8 | this.host = host; 9 | this.keySystem = keySystem; 10 | this.sessionId = sessionId; 11 | this.tab = tab; 12 | } 13 | 14 | async generateChallenge(pssh) { 15 | const challengeB64 = "CAES2A4KiA4IARKBCgrBAggCEiBttxwCmSgwbjwQ2zlHdERS+rHgVG9ZeDagqUxltJc8phj70/qwBiKOAjCCAQoCggEBAOpt50IEyOxI7GVJQ0B1UWF55ORUXQDynDCezUSFO4QsEf5hM2Qtmw9Vyujk9EPncV415IkuyO1lYkgcZr94TeZYMKSkT560y0FADshvyNfRFBu5+ib90er34swD+EOkjPrwkiERz43UCTVe" + 16 | "FfB24TxC1nV/n4HpyMkaFHVejxMdjIUouoLdl3Rd2501om/SMCBlCRzjJahI/0/+wEG9Ly+9+uP3eVAb63WOtD40F9DTeX4d98ZdZQzg0l+BAqVAEwEcVRQKlsEoXFk1yggu0RPN7mqGs1VhzxCMjp0P8gzkXTBOrNh+RffplGxoUzuxZzMcUHmTOek5RI70Hiq1UY0CAwEAASi9sAFIARKAAll91kCVLvPDxJC9x/AF+al/Hz8PBzqr0DxtGiEZj" + 17 | "nxkvRQDDL6H9Iftz7/0HVKMwvt7MCitWZYjT2Lv0adMhwEmx3dGK+oXnJS2ig6PO/w6iH8+U4K0zd8nqEY1CvIzhLd2nKKKGpZm3EqL+AXhVEAusavu7XtJ3fYT+b1cgohujultiIPqAcKhTGND8pIXBxVB3gNzQ3eRQ91OoSCxvs2Zu+ABubt6C7CmkPcZEgtJQKvCHa3OFIwvVjpeiJ+ieH823l0mzKgEjXiN5hGgWX+UoHO7aNCxMmA27ggWaT" + 18 | "LgbIDQ5UaItxXcw5S7Uc0n/llK5z7leOtlNZQst/sxyhIatwUKsQIIARIQRIFuOL9Iss7LvBSYxuAWfhjS4+2EBiKOAjCCAQoCggEBAMhkrbZ++0BWv3ecM2jOixUZ5TDKEFqivM9gnW6IODk4aXJ/0dE0lMo/EjcX3svSjvZCH9wvNq3pykGUrS8TD3ZcMPQLKr2qUq2lGEsyYXKtNVe9A22ak3cX9QQsND0/2tRhPThIv0vgj4A7aafyTZWlktW" + 19 | "ToTkqNqK8uqxXMRn9jKZgzO0BCul8i7j+yhsvAHedH1hHeAX77MBYCwf75wds0Uu10sG17FV6im28V0uVscQ2UyfA3N1E0LsypVf1Zm/ZxyhLgvjVxSpPb+O7TEIZa4Yw/ObOlRLVGZboWiOz/hp8QnREvJfkgeaUEfhPcThlqusrg+TmP18j6fiYbN8CAwEAASi9sAFIARKAA2PlkgEPBq+hEbSGJbFLF2TF6CIzLRpFKbBlilLiqMf76Gk/E24C" + 20 | "y1Yo6TqigkucXpvzM6aAa3Qk//e8N34csXSQTsBNIyS8z/IvaEE6k5rrZhk5EO7kqW5aiOBTdFuzHTdEKjFyH37Jrg7TZI+R6UduDJf4MOY75NdXWw/74Eo2ua2zXaQOe2LejY/sg8AkFj5yV1YU7dwO54znxHyRfk/L4XaoQDt509Qsmf6+DBhWG5DpQxxDYxL45fdQYIqJU+5UsLhTzZWVzAQyV5Cevc9Xa1fZJ/GY2V0Q+CZWFk+FZbRln/yW8" + 21 | "i9PDbARTNTmX+OIEJk1sHatdb6yhxMXKKuElS+nI0sDGZkkU8bOAnX3mNoyccNirCdiI6+qsPR46YvK2Wa6fJ2YCGYZ97T6cu2sqRRHXdwsZhBYJ/0ffANXxEtwe56KSP4x+2u3ALNGs+8UNAc9zZN5YofBgxDxKvRbzEQx3lv5ZA6Tfd7BJ8aLU6xPJN9PRXfFfxl0T+j2EBomChBhcHBsaWNhdGlvbl9uYW1lEhJjb20uYW5kcm9pZC5jaHJvbW" + 22 | "UaTgoecGFja2FnZV9jZXJ0aWZpY2F0ZV9oYXNoX2J5dGVzEiw4UDFzVzBFUEpjc2x3N1V6UnNpWEw2NHcrTzUwRWQrUkJJQ3RheTFnMjRNPRoXCgxjb21wYW55X25hbWUSB3NhbXN1bmcaFgoKbW9kZWxfbmFtZRIIU00tQTAyNUcaIAoRYXJjaGl0ZWN0dXJlX25hbWUSC2FybWVhYmktdjdhGhMKC2RldmljZV9uYW1lEgRhMDJxGhkKDHByb2R" + 23 | "1Y3RfbmFtZRIJYTAycW5hZWVhGlcKCmJ1aWxkX2luZm8SSXNhbXN1bmcvYTAycW5hZWVhL2EwMnE6MTIvU1AxQS4yMTA4MTIuMDE2L0EwMjVHWFhVNUNWSzE6dXNlci9yZWxlYXNlLWtleXMaHgoUd2lkZXZpbmVfY2RtX3ZlcnNpb24SBjE2LjAuMBokCh9vZW1fY3J5cHRvX3NlY3VyaXR5X3BhdGNoX2xldmVsEgEwGlAKHG9lbV9jcnlwdG9f" + 24 | "YnVpbGRfaW5mb3JtYXRpb24SME9FTUNyeXB0byBMZXZlbDMgQ29kZSAyMjU4OSBNYXkgMjggMjAyMSAxOTozNzoxOTIUCAEQASAAKBAwAEAASABQAVgAYAESPAo6ChQIARIQnrQFDeRLSAKTLifXUIPiZhABGiBmMGY5MGQ3NzRmNjEyMjIzMDEwMDAwMDAwMDAwMDAwMBgBIMLRysUGMBU4haiILhqAAqzESonqxmvkdmoDk89ih2Hz/BDfGtylH" + 25 | "u80jTu4/U1B2wMCz/RP5Ze5O5R7S2ewiKM0WYHPXmnKD3Dk/Mb4ri5RIxEUJvwc4RrwEhkb9nEqmbJ1Nmw8rtloYF1CNgyczHKRMs3t9DtllSi3AfkO7s4k+x+OHSXj06CBa2hjyNKhMYZOTP9yotXhGa2pQM2+skDuGDrTHRFRH+4McGBiRtI5MDtQzfE17wz3XkC2JzP2pXUiZLnRI1qSEfikkMEveFsW/vGl7Sj0rT+xl/yrWH66KZ4zXqg7mj" + 26 | "WNn+YtTN6Rbx0tUV8WLSbezQi+ILwUBPnpOE3kBCZWbX66u4pmzE4=" 27 | 28 | return challengeB64; 29 | } 30 | 31 | async parseLicense(license) { 32 | return { 33 | type: "WIDEVINE", 34 | pssh: "AAAAXHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAADwSEDAvgN1BHkiGvKW7H4AYoCQSEDAvgN1BHkiGvKW7H4AYoCQSEDAvgN1BHkiGvKW7H4AYoCRI88aJmwY=", 35 | keys: [{ 36 | kid: "9eb4050de44b4802932e27d75083e266", 37 | k: "166634c675823c235a4a9446fad52e4d" 38 | }, { 39 | kid: "302f80dd411e4886bca5bb1f8018a024", 40 | k: "15b2aaf906ebec6309d40f91289127b8" 41 | }] 42 | }; 43 | } 44 | } -------------------------------------------------------------------------------- /src/lib/customhandlers/knownkeys.js: -------------------------------------------------------------------------------- 1 | import { 2 | base64toUint8Array, 3 | uint8ArrayToHex, 4 | AsyncLocalStorage, 5 | AsyncSessionStorage 6 | } from "../../util.js"; 7 | 8 | export default class KnownKeysDevice { 9 | constructor(host, keySystem, sessionId, tab) { 10 | this.tab = tab; 11 | this.storage = this.tab.incognito ? AsyncSessionStorage : AsyncLocalStorage; 12 | } 13 | 14 | async generateChallenge(pssh) { 15 | this.pssh = pssh; 16 | this.kids = getKeyIdFromPSSH(pssh); 17 | 18 | // Send a "service certificate challenge" message to (by)pass the license server's device check 19 | // Upon receiving an 'update' call with a service certificate, just load the keys at this point 20 | // instead of sending an actual license request (the received service certificate is ignored) 21 | return "CAQ="; 22 | } 23 | 24 | async parseLicense(license) { // license: ignored, most likely service certificate data 25 | const logs = Object.values(await this.storage.getStorage()); 26 | let keys = this.kids.map(kid => ({ kid })); 27 | for (const keyObj of keys) { 28 | const kidMatch = logs.find(log => log.keys.some(key => key.kid.toLowerCase() === keyObj.kid.toLowerCase())); 29 | if (kidMatch) { 30 | const key = kidMatch.keys.find(key => key.kid.toLowerCase() === keyObj.kid.toLowerCase()); 31 | keyObj.k = key.k; 32 | } 33 | } 34 | if (keys.length === 0 || keys.some(k => !k.k) || !this.kids.every(kid => keys.some(k => k.kid.toLowerCase() === kid.toLowerCase()))) { 35 | const psshMatch = logs.find(log => log.pssh === this.pssh); 36 | const urlMatch = logs.find(log => log.url === this.tab.url); 37 | const res = await chrome.scripting.executeScript({ 38 | target: { tabId: this.tab.id }, 39 | func: askForKeys, 40 | args: [this.pssh, this.kids, psshMatch?.keys || urlMatch?.keys || []] 41 | }); 42 | keys = res[0].result; 43 | } 44 | if (keys.length === 0 || keys.some(k => !k.k) || !this.kids.every(kid => keys.some(k => k.kid.toLowerCase() === kid.toLowerCase()))) { 45 | throw new Error("Not all required keys were provided"); 46 | } 47 | return { 48 | type: "WIDEVINE", 49 | pssh: this.pssh, 50 | keys: keys 51 | }; 52 | } 53 | } 54 | 55 | // Only works with Widevine PSSH boxes that contain KIDs 56 | function getKeyIdFromPSSH(pssh) { 57 | const psshBytes = base64toUint8Array(pssh); 58 | const kids = []; 59 | let offset = 0x20; 60 | while (offset < psshBytes.length) { 61 | if (psshBytes[offset] === 0x12 && psshBytes[offset + 1] === 0x10) { 62 | offset += 2; 63 | const kid = psshBytes.subarray(offset, offset + 16); 64 | kids.push(uint8ArrayToHex(kid)); 65 | offset += 16; 66 | } else { 67 | break; 68 | } 69 | } 70 | return kids; 71 | } 72 | 73 | function askForKeys(pssh, kids, candidate) { 74 | let msg = `Provide keys in the kid:key format (hex), separated by spaces.\nPSSH: ${pssh}` 75 | if (kids.length > 0) { 76 | msg += `KIDs: ${kids.join(", ")}`; 77 | } 78 | if (candidate.length > 0) { 79 | msg += "\nLeave blank and press OK to use the following keys from a log entry with matched PSSH or URL:"; 80 | msg += "\n" + candidate.map(key => `${key.kid}:${key.k}`).join(" "); 81 | } 82 | const res = window.prompt(msg, ""); 83 | if (res === null) return []; 84 | if (res.trim() === "") { 85 | if (candidate.length > 0) { 86 | return candidate; 87 | } 88 | return []; 89 | } 90 | const regex = /([0-9a-fA-F]+):([0-9a-fA-F]+)/g; 91 | const keys = []; 92 | let match; 93 | do { 94 | match = regex.exec(res); 95 | if (match) { 96 | const [_, kid, k] = match; 97 | keys.push({ kid, k }); 98 | } 99 | } while (match); 100 | return keys; 101 | } -------------------------------------------------------------------------------- /src/pages/panel/panel.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, sans-serif; 3 | background-color: #ffffff; 4 | color: black; 5 | width: 400px; 6 | overflow-x: hidden; 7 | overflow-y: scroll; 8 | user-select: none; 9 | font-size: 13px; 10 | } 11 | .toggleButton { 12 | width: 24px; 13 | } 14 | .text-box { 15 | outline: none; 16 | width: 80%; 17 | user-select: auto; 18 | } 19 | #downloader-name { 20 | width: 160px; 21 | } 22 | 23 | a { 24 | text-decoration: none; 25 | } 26 | 27 | a:hover { 28 | text-decoration: underline; 29 | } 30 | 31 | /* Switch */ 32 | #scopeToggleArea { 33 | margin-left: 10px; 34 | display: flex; 35 | gap: 4px; 36 | } 37 | .switch { 38 | position: relative; 39 | display: inline-block; 40 | width: 40px; 41 | height: 20px; 42 | } 43 | .switch input { 44 | opacity: 0; 45 | width: 0; 46 | height: 0; 47 | } 48 | .scopeLabel { 49 | max-width: 120px; 50 | overflow: hidden; 51 | text-overflow: ellipsis; 52 | white-space: nowrap; 53 | display: inline-block; 54 | direction: rtl; 55 | height: 15px; 56 | } 57 | #siteScopeArea { 58 | position: relative; 59 | } 60 | #siteScopeLabel { 61 | cursor: pointer; 62 | } 63 | #scopeInput { 64 | position: absolute; 65 | width: 120px; 66 | top: 0; 67 | left: 0; 68 | display: none; 69 | } 70 | 71 | #reload { 72 | margin-left: auto; 73 | } 74 | #reload.hidden { 75 | visibility: hidden; 76 | } 77 | 78 | /* Slider */ 79 | .slider { 80 | position: absolute; 81 | cursor: pointer; 82 | top: 0; 83 | left: 0; 84 | right: 0; 85 | bottom: 0; 86 | background-color: #ccc; 87 | transition: 0.4s; 88 | border-radius: 20px; 89 | } 90 | .slider:before { 91 | position: absolute; 92 | content: ""; 93 | height: 14px; 94 | width: 14px; 95 | left: 3px; 96 | bottom: 3px; 97 | background-color: white; 98 | border-radius: 50%; 99 | } 100 | input:checked + .slider { 101 | background-color: #2196F3 !important; 102 | } 103 | input:checked + .slider:before { 104 | transform: translateX(20px); 105 | } 106 | input:disabled + .slider { 107 | opacity: 0.5; 108 | cursor: default; 109 | } 110 | 111 | input.hidden:not(:checked), 112 | input.hidden:not(:checked) + label { 113 | display: none; 114 | } 115 | 116 | #keyButtons { 117 | width: 100%; 118 | display: flex; 119 | margin-bottom: 3px; 120 | gap: 5px; 121 | } 122 | #keyButtons > button { 123 | flex: 1; 124 | } 125 | 126 | #system > div { 127 | display: flex; 128 | } 129 | 130 | #settings { 131 | display: flex; 132 | } 133 | 134 | .header { 135 | display: flex; 136 | justify-content: center; 137 | align-items: center; 138 | } 139 | .header > * { 140 | margin: 5px; 141 | } 142 | h1 { 143 | margin: 0; 144 | font-size: 28px; 145 | } 146 | .header-right { 147 | font-size: 11px; 148 | } 149 | #version { 150 | position: absolute; 151 | top: 0; 152 | right: 0; 153 | } 154 | 155 | #systemsBottomArea { 156 | display: flex; 157 | gap: 5px; 158 | } 159 | 160 | #wv-server-cert, 161 | #max-robustness { 162 | margin-top: 2px; 163 | } 164 | 165 | #wvd, #remote, #custom, #prd, #pr-remote, #pr-custom { 166 | display: none; 167 | } 168 | 169 | #main[data-wv-type="local"] #wvd { 170 | display: block; 171 | } 172 | #main[data-wv-type="remote"] #remote { 173 | display: block; 174 | } 175 | #main[data-wv-type="custom"] #custom { 176 | display: block; 177 | } 178 | #main[data-pr-type="local"] #prd { 179 | display: block; 180 | } 181 | #main[data-pr-type="remote"] #pr-remote { 182 | display: block; 183 | } 184 | #main[data-pr-type="custom"] #pr-custom { 185 | display: block; 186 | } 187 | 188 | .device-fieldset button { 189 | margin-right: 5px; 190 | } 191 | 192 | .device-fieldset select { 193 | margin-top: 5px; 194 | } 195 | 196 | p[id*="custom-desc"] { 197 | margin: 0; 198 | margin-top: 5px; 199 | } 200 | 201 | select[id*="-combobox"] { 202 | width: 100%; 203 | } 204 | 205 | #command-options fieldset > div { 206 | margin-bottom: 5px; 207 | } 208 | 209 | .log-container { 210 | display: flex; 211 | justify-content: center; 212 | } 213 | .right-bound { 214 | text-align: right; 215 | } 216 | .expandableDiv { 217 | width: 100%; 218 | overflow: hidden; 219 | background-color: lightblue; 220 | padding: 0; 221 | position: relative; 222 | } 223 | .expandableDiv.expanded { 224 | padding: 5px; 225 | } 226 | .expandableDiv.collapsed { 227 | padding: 0; 228 | } 229 | .always-visible { 230 | display: block; 231 | } 232 | .expanded-only { 233 | display: none; 234 | } 235 | .expandableDiv.expanded .expanded-only { 236 | display: block; 237 | } 238 | .removeButton { 239 | position: absolute; 240 | top: 5px; 241 | left: 7px; 242 | } 243 | 244 | #overlay { 245 | position: absolute; 246 | top: 0; 247 | left: 0; 248 | width: 100%; 249 | height: 100%; 250 | background: rgba(0, 0, 0, 0.7); 251 | color: white; 252 | display: flex; 253 | align-items: center; 254 | justify-content: center; 255 | text-align: center; 256 | } 257 | #overlayMessage { 258 | margin: 30px; 259 | } 260 | /* ...Shrink Settings Menu... */ 261 | .collapsible-section[open] { 262 | opacity: 1; 263 | pointer-events: auto; 264 | filter: none; 265 | } 266 | .collapsible-section:not([open]) { 267 | opacity: 0.5; 268 | pointer-events: none; 269 | } 270 | .collapsible-section > summary { 271 | cursor: pointer; 272 | font-weight: bold; 273 | background: #eee; 274 | padding: 4px 8px; 275 | border-radius: 4px; 276 | outline: none; 277 | } 278 | .collapsible-section:not([open]) > summary { 279 | opacity: 1; 280 | pointer-events: auto; 281 | filter: none; 282 | } 283 | 284 | /* Dark mode */ 285 | @media (prefers-color-scheme: dark) { 286 | body { 287 | background-color: #1b1b1b; 288 | color: #d5d5d5; 289 | } 290 | a { 291 | color: dodgerblue; 292 | } 293 | fieldset { 294 | border-color: #535353; 295 | } 296 | input, button, select { 297 | color-scheme: dark; 298 | } 299 | option { 300 | color: #d5d5d5; 301 | } 302 | .expandableDiv { 303 | background-color: #1b2027; 304 | } 305 | .slider { 306 | background-color: gray !important; 307 | } 308 | .collapsible-section > summary { 309 | background: #23272e; 310 | color: #ffffff; 311 | } 312 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vineless Icon Vineless 2 | * A browser extension to play DRM-protected content without a real CDM 3 | * Works by redirecting the content keys to the browser's ClearKey handler 4 | 5 | ## Features 6 | + User-friendly / GUI-based 7 | + Supports Widevine and PlayReady-protected content 8 | + Manifest V3 compliant 9 | + Per-site configuration 10 | 11 | ## Devices 12 | * This addon requires a Widevine/PlayReady Device file (`*.wvd`/`*.prd`) to work. Don't ask me where to get those. 13 | * For remote devices, you can find samples in the `devices` directory. Some of them might require modification to work with your specific use case. 14 | 15 | ## Compatibility 16 | + Should be compatible with all modern browsers that support the standard EME, the ClearKey CDM, and the Manifest V3 extensions 17 | + Tested browsers 18 | + Chrome, Edge, Brave, Supermium, ungoogled-chromium, Firefox, LibreWolf, and Marble on Windows 19 | + Lemur Browser on Android 20 | + Incompatible browsers: 21 | + Tor Browser (does not support ClearKey) 22 | + Firefox for Android, and its forks, such as Kiwi Browser (does not support ClearKey) 23 | + Safari and other WebKit-based browsers (its ClearKey implementation is weird and non-standard I think) 24 | + Internet Explorer, obviously. 25 | + Works with any service that accepts challenges from Android devices on the same endpoint. 26 | + Services incompatible with mobile/TV devices: 27 | + Netflix 28 | + VdoCipher (if 2074 error occurs) 29 | + CBS 30 | + Fastevo 31 | + Rakuten TV (only applies to Widevine) 32 | + Some services may detect your browser and interfere with PlayReady playback. Try using a user-agent changer extension, or use a Chromium-based browser for PlayReady playback. 33 | + Firefox-based browsers may fail to play some PlayReady-protected video, with an internal error saying `ChromiumCDMParent::RecvDecodeFailed with status decode error`. This is a problem with the browser's ClearKey handler, and Vineless can do nothing about it. Please use a Chromium-based browser if this error occurs. 34 | + Incompatible extensions: 35 | + WidevineProxy2, PlayreadyProxy2, or anything similar 36 | + EME Call and Event Logger (extension, not the userscript) 37 | + For Vineless to work, these extensions must be fully disabled in the browser's extensions page 38 | 39 | ## Installation 40 | + Chrome 41 | 1. Download the ZIP file from the [releases section](https://github.com/Ingan121/Vineless/releases) 42 | 2. Navigate to `chrome://extensions/` 43 | 3. Enable `Developer mode` 44 | 4. Drag-and-drop the downloaded file into the window 45 | + Firefox 46 | + Persistent installation 47 | 1. Download the XPI file from the [releases section](https://github.com/Ingan121/Vineless/releases) 48 | 2. Navigate to `about:addons` 49 | 3. Click the settings icon and choose `Install Add-on From File...` 50 | 4. Select the downloaded file 51 | + Temporary installation 52 | 1. Download the ZIP file from the [releases section](https://github.com/Ingan121/Vineless/releases) 53 | 2. Navigate to `about:debugging#/runtime/this-firefox` 54 | 3. Click `Load Temporary Add-on...` and select the downloaded file 55 | 56 | ## Setup 57 | + Open the extension and select the type of device you're using in the top `Systems` section 58 | + Click one of the `Choose File` buttons to select device files 59 | + You're all set! 60 | 61 | ## Notes 62 | + 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. 63 | + The maximum number of devices is ~25 Local **OR** ~200 Remote CDMs 64 | + The maximum number of per-site profiles is ~200 profiles 65 | + The number of saved key logs is unlimited as long as your disk space allows 66 | 67 | ## Usage 68 | All the user has to do is to play a DRM protected video. With everything set up properly, videos will start to play even without a supported DRM system. 69 | 70 | ## FAQ 71 | > What if I'm unable to play the video? 72 | 73 | * First, check if the service accepts your device and is working correctly. 74 | * For Widevine, use either [WidevineProxy2](https://github.com/DevLARLEY/WidevineProxy2) or [openwv](https://github.com/tchebb/openwv) with the same WVD file. 75 | * For PlayReady, use [PlayreadyProxy2](https://github.com/DevLARLEY/PlayreadyProxy2/) with the same PRD file. 76 | * For ClearKey, just fully disable Vineless and test the playback with the non-intercepted ClearKey handler. 77 | * Do note that WidevineProxy2 and PlayreadyProxy2 do not support playback, so just test if you can acquire the keys with them. Also, fully disable Vineless before testing those two. 78 | * If those aren't working as well, this automatically means that the license server is blocking your CDM and that you either need a CDM from a physical device, a ChromeCDM, or an L1 Android CDM. Don't ask where you can get these. 79 | * If those are working but Vineless isn't working, please report this on the issues page. Please include the DevTools console logs as well, and make sure the verbose/debug logs are enabled. 80 | 81 | ## Build 82 | * Requirements: Node.js (with npm), Git (if cloning from GitHub) 83 | ```sh 84 | git clone https://github.com/Ingan121/Vineless 85 | cd Vineless 86 | npm install 87 | npm run build 88 | # The build output will be in the `dist` directory 89 | ``` 90 | 91 | ## Disclaimer 92 | + This program is intended solely for educational purposes. 93 | + Do not use this program to decrypt or access any content for which you do not have the legal rights or explicit permission. 94 | + Unauthorized decryption or distribution of copyrighted materials is a violation of applicable laws and intellectual property rights. 95 | + 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. 96 | + The developers, contributors, and maintainers of this program are not responsible for any misuse or illegal activities performed using this software. 97 | + By using this program, you agree to comply with all applicable laws and regulations governing digital rights and copyright protections. 98 | 99 | ## Credits 100 | + [WidevineProxy2](https://github.com/DevLARLEY/WidevineProxy2) 101 | + [PlayreadyProxy2](https://github.com/DevLARLEY/PlayreadyProxy2/tree/f4965f809dbea1a309e1fd50c072f50bf08fb03c) 102 | + [node-widevine](https://github.com/Frooastside/node-widevine) 103 | + [forge](https://github.com/digitalbazaar/forge) 104 | + [protobuf.js](https://github.com/protobufjs/protobuf.js) 105 | + [noble-curves](https://github.com/paulmillr/noble-curves) 106 | + [xmldom](https://github.com/xmldom/xmldom) 107 | -------------------------------------------------------------------------------- /src/pages/panel/panel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Vineless Panel UI 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | Vineless Icon 13 |
14 |

Vineless

15 | Made by Ingan121 - www.ingan121.com
16 | Based on WidevineProxy2 and PlayreadyProxy2 17 |
18 |

v1.x

19 |
20 |
21 | Settings 22 |
23 | 24 | 25 |
26 |
27 | 28 | 32 |
33 | 34 | 35 |
36 |
37 | 38 |
39 |
40 | Systems & Devices 41 |
42 | Systems 43 |
44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
53 |
54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |
63 |
64 |
65 | 66 | 67 |
68 |
69 | 70 | 71 |
72 |
73 |
74 |
75 | Widevine Local 76 | 77 | 78 |
79 | 80 |
81 |
82 | Widevine Remote 83 | 84 | 85 |
86 | 87 |
88 |
89 | Widevine Custom 90 | 91 |

92 |
93 |
94 | PlayReady Local 95 | 96 | 97 |
98 | 99 |
100 |
101 | PlayReady Remote 102 | 103 | 104 |
105 | 106 |
107 |
108 | PlayReady Custom 109 | 110 |

111 |
112 |
113 |
114 | Command Options 115 |
116 | Command Options 117 |
118 | 119 | 124 |
125 |
126 | 127 | 131 | 132 | 137 |
138 |
139 | 140 | 144 |
145 |
146 |
147 | 148 | 149 | 150 | 151 |
152 | 153 | 154 |
155 |
156 |
157 | Advanced Options 158 |
159 | Advanced Options 160 |
161 | 162 | 167 |
168 |
169 | 170 | 178 |
179 |
180 | 181 | 182 | 2.3 183 |
184 |
185 | 186 | 187 |
188 |
189 | 190 | 191 |
192 |
193 |
194 |
195 | Keys 196 |
197 | 198 | 199 |
200 |
201 |
202 |
203 |

Checking browser compatibility...

204 |
205 | 206 | 207 | -------------------------------------------------------------------------------- /src/lib/playready/cdm.js: -------------------------------------------------------------------------------- 1 | import { ElGamal } from "./elgamal.js"; 2 | import { Crypto } from "./crypto.js"; 3 | import { EccKey } from "./ecc_key.js"; 4 | import { Utils } from "./utils.js"; 5 | import { Key } from "./key.js"; 6 | import { utils } from "./noble-curves.min.js"; 7 | import { XmrLicense } from "./xmr_license.js"; 8 | import { DOMParser } from './xmldom.min.js'; 9 | import { XmlKey } from "./xml_key.js"; 10 | 11 | export class Cdm { 12 | constructor(certificate_chain, encryption_key, signing_key) { 13 | this.certificate_chain = certificate_chain; 14 | this.encryption_key = EccKey.loads(encryption_key); 15 | this.signing_key = EccKey.loads(signing_key); 16 | 17 | this.rgbMagicConstantZero = new Uint8Array([0x7e, 0xe9, 0xed, 0x4a, 0xf7, 0x73, 0x22, 0x4f, 0x00, 0xb8, 0xea, 0x7e, 0xfb, 0x02, 0x7c, 0xbb]); 18 | this._wmrmServerKey = { 19 | x: 90785344306297710604867503975059265028223978614363440949957868233137570135451n, 20 | y: 68827801477692731286297993103001909218341737652466656881935707825713852622178n 21 | }; 22 | 23 | this.parser = new DOMParser(); 24 | } 25 | 26 | static fromDevice(device) { 27 | return new Cdm( 28 | device.group_certificate, 29 | device.encryption_key, 30 | device.signing_key 31 | ); 32 | } 33 | 34 | _getKeyCipher(xml_key) { 35 | const encrypted = ElGamal.encrypt(xml_key.get_point(), this._wmrmServerKey); 36 | return new Uint8Array([ 37 | ...utils.numberToBytesBE(encrypted.point1.x, 32), 38 | ...utils.numberToBytesBE(encrypted.point1.y, 32), 39 | ...utils.numberToBytesBE(encrypted.point2.x, 32), 40 | ...utils.numberToBytesBE(encrypted.point2.y, 32) 41 | ]); 42 | } 43 | 44 | _getDataCipher(xml_key) { 45 | const b64CertificateChain = Utils.bytesToBase64(this.certificate_chain); 46 | const body = `${b64CertificateChain}""`; 47 | 48 | const ciphertext = Crypto.aesCbcEncrypt( 49 | xml_key.aes_key, 50 | xml_key.aes_iv, 51 | Utils.stringToBytes(body) 52 | ); 53 | 54 | return new Uint8Array([ 55 | ...xml_key.aes_iv, 56 | ...ciphertext 57 | ]); 58 | } 59 | 60 | _buildDigestInfo(digest_value) { 61 | return ( 62 | `` + 63 | `` + 64 | `` + 65 | `` + 66 | `` + 67 | `${digest_value}` + 68 | `` + 69 | `` 70 | ); 71 | } 72 | 73 | _buildDigestContent(content_header, nonce, key_cipher, data_cipher, rev_lists, protocol_version, client_version) { 74 | const clientTime = Math.floor(Date.now() / 1000); 75 | 76 | return ( 77 | `` + 78 | `${protocol_version}` + 79 | `${content_header}` + 80 | `` + 81 | `${client_version}` + 82 | `` + 83 | rev_lists + 84 | `${nonce}` + 85 | `${clientTime}` + 86 | `` + 87 | `` + 88 | `` + 89 | `` + 90 | `` + 91 | `` + 92 | `WMRMServer` + 93 | `` + 94 | `` + 95 | `${key_cipher}` + 96 | `` + 97 | `` + 98 | `` + 99 | `` + 100 | `${data_cipher}` + 101 | `` + 102 | `` + 103 | `` 104 | ); 105 | } 106 | 107 | _buildMainBody(la_content, signed_info, signature_value, public_key) { 108 | return ( 109 | '' + 110 | '' + 111 | '' + 112 | '' + 113 | '' + 114 | '' + 115 | la_content + 116 | '' + 117 | signed_info + 118 | `${signature_value}` + 119 | '' + 120 | '' + 121 | '' + 122 | `${public_key}` + 123 | '' + 124 | '' + 125 | '' + 126 | '' + 127 | '' + 128 | '' + 129 | '' + 130 | '' + 131 | '' 132 | ); 133 | } 134 | 135 | getLicenseChallenge(wrm_header, rev_lists, client_version) { 136 | const xml_key = new XmlKey(); 137 | 138 | const wrmHeaderDoc = this.parser.parseFromString(wrm_header, "application/xml").documentElement; 139 | const wrmHeaderVersion = wrmHeaderDoc.getAttribute("version"); 140 | 141 | let protocol_version = 1; 142 | 143 | switch (wrmHeaderVersion){ 144 | case "4.3.0.0": 145 | protocol_version = 5 146 | break; 147 | case "4.2.0.0": 148 | protocol_version = 4 149 | break; 150 | } 151 | 152 | const laContent = this._buildDigestContent( 153 | wrm_header, 154 | Utils.bytesToBase64(Crypto.randomBytes(16)), 155 | Utils.bytesToBase64(this._getKeyCipher(xml_key)), 156 | Utils.bytesToBase64(this._getDataCipher(xml_key)), 157 | rev_lists, 158 | protocol_version, 159 | client_version ?? "10.0.16384.10011" 160 | ); 161 | const contentHash = Crypto.sha256(laContent); 162 | 163 | const signedInfo = this._buildDigestInfo( 164 | Utils.bytesToBase64(contentHash) 165 | ); 166 | 167 | const signature = Crypto.ecc256Sign( 168 | this.signing_key.privateKey, 169 | signedInfo 170 | ); 171 | const rawSignature = new Uint8Array([ 172 | ...utils.numberToBytesBE(signature.r, 32), 173 | ...utils.numberToBytesBE(signature.s, 32) 174 | ]); 175 | 176 | const singing_key = this.signing_key.publicBytes(); 177 | 178 | return this._buildMainBody( 179 | laContent, 180 | signedInfo, 181 | Utils.bytesToBase64(rawSignature), 182 | Utils.bytesToBase64(singing_key) 183 | ); 184 | } 185 | 186 | parseLicense(rawLicense) { 187 | const xmlDoc = this.parser.parseFromString(rawLicense, "application/xml"); 188 | const licenseElements = xmlDoc.getElementsByTagName("License"); 189 | 190 | const keys = []; 191 | 192 | Array.from(licenseElements).forEach(licenseElement => { 193 | const license = XmrLicense.loads(Utils.base64ToBytes(licenseElement.textContent)); 194 | 195 | const isScalable = license.getObjects(81).length > 0; 196 | 197 | license.getObjects(10).forEach(obj => { 198 | const contentKeyObject = obj.data; 199 | 200 | if (![3, 4, 6].includes(contentKeyObject.cipher_type)) { 201 | return; 202 | } 203 | 204 | const viaSymmetric = contentKeyObject.cipher_type === 6; 205 | console.log( 206 | "cipher_type", contentKeyObject.cipher_type, 207 | "key_type", contentKeyObject.key_type, 208 | "isScalable", isScalable, 209 | "viaSymmetric", viaSymmetric, 210 | ); 211 | 212 | const encryptedKey = contentKeyObject.encrypted_key; 213 | const decrypted = Crypto.ecc256decrypt(this.encryption_key.privateKey, encryptedKey); 214 | 215 | let ci = decrypted.subarray(0, 16); 216 | let ck = decrypted.subarray(16, 32); 217 | 218 | if (isScalable) { 219 | ci = decrypted.filter((_, index) => index % 2 === 0).slice(0, 16); 220 | ck = decrypted.filter((_, index) => index % 2 === 1).slice(0, 16); 221 | 222 | if (viaSymmetric) { 223 | const embeddedRootLicense = encryptedKey.subarray(0, 144); 224 | let embeddedLeafLicense = encryptedKey.subarray(144); 225 | 226 | const rgbKey = Utils.xorArrays(ck, this.rgbMagicConstantZero); 227 | const contentKeyPrime = Crypto.aesEcbEncrypt(ck, rgbKey); 228 | 229 | const auxKey = license.getObjects(81)[0].data.auxiliary_keys[0].key; 230 | 231 | const uplinkXKey = Crypto.aesEcbEncrypt(contentKeyPrime, auxKey); 232 | const secondaryKey = Crypto.aesEcbEncrypt(ck, embeddedRootLicense.subarray(128)); 233 | 234 | embeddedLeafLicense = Crypto.aesEcbEncrypt(uplinkXKey, embeddedLeafLicense); 235 | embeddedLeafLicense = Crypto.aesEcbEncrypt(secondaryKey, embeddedLeafLicense); 236 | 237 | ci = embeddedLeafLicense.subarray(0, 16); 238 | ck = embeddedLeafLicense.subarray(16, 32); 239 | } 240 | } 241 | 242 | if (!license.checkSignature(ci)) { 243 | throw new Error("License integrity signature does not match"); 244 | } 245 | 246 | keys.push(new Key( 247 | contentKeyObject.key_id, 248 | contentKeyObject.key_type, 249 | contentKeyObject.cipher_type, 250 | ck, 251 | )); 252 | }); 253 | }); 254 | 255 | return keys; 256 | } 257 | } -------------------------------------------------------------------------------- /src/lib/widevine/license.js: -------------------------------------------------------------------------------- 1 | import { AES_CMAC } from "./cmac.js" 2 | import { 3 | compareUint8Arrays, 4 | uint8ArrayToHex, 5 | uint8ArrayToString, 6 | stringToUint8Array, 7 | stringToHex, 8 | base64toUint8Array, 9 | uint8ArrayToBase64, 10 | intToUint8Array 11 | } from "../../util.js" 12 | import { license_protocol } from "./license_protocol.js" 13 | import forge from "../forge.min.js"; 14 | 15 | const { ClientIdentification, DrmCertificate, EncryptedClientIdentification, License, LicenseRequest, LicenseType, 16 | ProtocolVersion, SignedDrmCertificate, SignedMessage, WidevinePsshData } = license_protocol; 17 | 18 | export const SERVICE_CERTIFICATE_CHALLENGE = new Uint8Array([0x08, 0x04]); 19 | 20 | const WIDEVINE_SYSTEM_ID = new Uint8Array([0xed, 0xef, 0x8b, 0xa9, 0x79, 0xd6, 0x4a, 0xce, 0xa3, 0xc8, 0x27, 0xdc, 0xd5, 0x1d, 0x21, 0xed]); 21 | const PSSH_MAGIC = new Uint8Array([0x70, 0x73, 0x73, 0x68]) 22 | 23 | const WIDEVINE_ROOT_PUBLIC_KEY = new Uint8Array([ 24 | 0x30, 0x82, 0x01, 0x8a, 0x02, 0x82, 0x01, 0x81, 0x00, 0xb4, 0xfe, 0x39, 0xc3, 0x65, 0x90, 0x03, 0xdb, 0x3c, 0x11, 0x97, 0x09, 0xe8, 0x68, 0xcd, 25 | 0xf2, 0xc3, 0x5e, 0x9b, 0xf2, 0xe7, 0x4d, 0x23, 0xb1, 0x10, 0xdb, 0x87, 0x65, 0xdf, 0xdc, 0xfb, 0x9f, 0x35, 0xa0, 0x57, 0x03, 0x53, 0x4c, 0xf6, 26 | 0x6d, 0x35, 0x7d, 0xa6, 0x78, 0xdb, 0xb3, 0x36, 0xd2, 0x3f, 0x9c, 0x40, 0xa9, 0x95, 0x26, 0x72, 0x7f, 0xb8, 0xbe, 0x66, 0xdf, 0xc5, 0x21, 0x98, 27 | 0x78, 0x15, 0x16, 0x68, 0x5d, 0x2f, 0x46, 0x0e, 0x43, 0xcb, 0x8a, 0x84, 0x39, 0xab, 0xfb, 0xb0, 0x35, 0x80, 0x22, 0xbe, 0x34, 0x23, 0x8b, 0xab, 28 | 0x53, 0x5b, 0x72, 0xec, 0x4b, 0xb5, 0x48, 0x69, 0x53, 0x3e, 0x47, 0x5f, 0xfd, 0x09, 0xfd, 0xa7, 0x76, 0x13, 0x8f, 0x0f, 0x92, 0xd6, 0x4c, 0xdf, 29 | 0xae, 0x76, 0xa9, 0xba, 0xd9, 0x22, 0x10, 0xa9, 0x9d, 0x71, 0x45, 0xd6, 0xd7, 0xe1, 0x19, 0x25, 0x85, 0x9c, 0x53, 0x9a, 0x97, 0xeb, 0x84, 0xd7, 30 | 0xcc, 0xa8, 0x88, 0x82, 0x20, 0x70, 0x26, 0x20, 0xfd, 0x7e, 0x40, 0x50, 0x27, 0xe2, 0x25, 0x93, 0x6f, 0xbc, 0x3e, 0x72, 0xa0, 0xfa, 0xc1, 0xbd, 31 | 0x29, 0xb4, 0x4d, 0x82, 0x5c, 0xc1, 0xb4, 0xcb, 0x9c, 0x72, 0x7e, 0xb0, 0xe9, 0x8a, 0x17, 0x3e, 0x19, 0x63, 0xfc, 0xfd, 0x82, 0x48, 0x2b, 0xb7, 32 | 0xb2, 0x33, 0xb9, 0x7d, 0xec, 0x4b, 0xba, 0x89, 0x1f, 0x27, 0xb8, 0x9b, 0x88, 0x48, 0x84, 0xaa, 0x18, 0x92, 0x0e, 0x65, 0xf5, 0xc8, 0x6c, 0x11, 33 | 0xff, 0x6b, 0x36, 0xe4, 0x74, 0x34, 0xca, 0x8c, 0x33, 0xb1, 0xf9, 0xb8, 0x8e, 0xb4, 0xe6, 0x12, 0xe0, 0x02, 0x98, 0x79, 0x52, 0x5e, 0x45, 0x33, 34 | 0xff, 0x11, 0xdc, 0xeb, 0xc3, 0x53, 0xba, 0x7c, 0x60, 0x1a, 0x11, 0x3d, 0x00, 0xfb, 0xd2, 0xb7, 0xaa, 0x30, 0xfa, 0x4f, 0x5e, 0x48, 0x77, 0x5b, 35 | 0x17, 0xdc, 0x75, 0xef, 0x6f, 0xd2, 0x19, 0x6d, 0xdc, 0xbe, 0x7f, 0xb0, 0x78, 0x8f, 0xdc, 0x82, 0x60, 0x4c, 0xbf, 0xe4, 0x29, 0x06, 0x5e, 0x69, 36 | 0x8c, 0x39, 0x13, 0xad, 0x14, 0x25, 0xed, 0x19, 0xb2, 0xf2, 0x9f, 0x01, 0x82, 0x0d, 0x56, 0x44, 0x88, 0xc8, 0x35, 0xec, 0x1f, 0x11, 0xb3, 0x24, 37 | 0xe0, 0x59, 0x0d, 0x37, 0xe4, 0x47, 0x3c, 0xea, 0x4b, 0x7f, 0x97, 0x31, 0x1c, 0x81, 0x7c, 0x94, 0x8a, 0x4c, 0x7d, 0x68, 0x15, 0x84, 0xff, 0xa5, 38 | 0x08, 0xfd, 0x18, 0xe7, 0xe7, 0x2b, 0xe4, 0x47, 0x27, 0x12, 0x11, 0xb8, 0x23, 0xec, 0x58, 0x93, 0x3c, 0xac, 0x12, 0xd2, 0x88, 0x6d, 0x41, 0x3d, 39 | 0xc5, 0xfe, 0x1c, 0xdc, 0xb9, 0xf8, 0xd4, 0x51, 0x3e, 0x07, 0xe5, 0x03, 0x6f, 0xa7, 0x12, 0xe8, 0x12, 0xf7, 0xb5, 0xce, 0xa6, 0x96, 0x55, 0x3f, 40 | 0x78, 0xb4, 0x64, 0x82, 0x50, 0xd2, 0x33, 0x5f, 0x91, 0x02, 0x03, 0x01, 0x00, 0x01 41 | ]); 42 | 43 | const STAGING_PRIVACY_CERT = new Uint8Array([ 44 | 0x0a, 0xbf, 0x02, 0x08, 0x03, 0x12, 0x10, 0x28, 0x70, 0x34, 0x54, 0xc0, 0x08, 0xf6, 0x36, 0x18, 0xad, 0xe7, 0x44, 0x3d, 0xb6, 0xc4, 0xc8, 0x18, 45 | 0x8b, 0xe7, 0xf9, 0x90, 0x05, 0x22, 0x8e, 0x02, 0x30, 0x82, 0x01, 0x0a, 0x02, 0x82, 0x01, 0x01, 0x00, 0xb5, 0x21, 0x12, 0xb8, 0xd0, 0x5d, 0x02, 46 | 0x3f, 0xcc, 0x5d, 0x95, 0xe2, 0xc2, 0x51, 0xc1, 0xc6, 0x49, 0xb4, 0x17, 0x7c, 0xd8, 0xd2, 0xbe, 0xef, 0x35, 0x5b, 0xb0, 0x67, 0x43, 0xde, 0x66, 47 | 0x1e, 0x3d, 0x2a, 0xbc, 0x31, 0x82, 0xb7, 0x99, 0x46, 0xd5, 0x5f, 0xdc, 0x08, 0xdf, 0xe9, 0x54, 0x07, 0x81, 0x5e, 0x9a, 0x62, 0x74, 0xb3, 0x22, 48 | 0xa2, 0xc7, 0xf5, 0xe0, 0x67, 0xbb, 0x5f, 0x0a, 0xc0, 0x7a, 0x89, 0xd4, 0x5a, 0xea, 0x94, 0xb2, 0x51, 0x6f, 0x07, 0x5b, 0x66, 0xef, 0x81, 0x1d, 49 | 0x0d, 0x26, 0xe1, 0xb9, 0xa6, 0xb8, 0x94, 0xf2, 0xb9, 0x85, 0x79, 0x62, 0xaa, 0x17, 0x1c, 0x4f, 0x66, 0x63, 0x0d, 0x3e, 0x4c, 0x60, 0x27, 0x18, 50 | 0x89, 0x7f, 0x5e, 0x1e, 0xf9, 0xb6, 0xaa, 0xf5, 0xad, 0x4d, 0xba, 0x2a, 0x7e, 0x14, 0x17, 0x6d, 0xf1, 0x34, 0xa1, 0xd3, 0x18, 0x5b, 0x5a, 0x21, 51 | 0x8a, 0xc0, 0x5a, 0x4c, 0x41, 0xf0, 0x81, 0xef, 0xff, 0x80, 0xa3, 0xa0, 0x40, 0xc5, 0x0b, 0x09, 0xbb, 0xc7, 0x40, 0xee, 0xdc, 0xd8, 0xf1, 0x4d, 52 | 0x67, 0x5a, 0x91, 0x98, 0x0f, 0x92, 0xca, 0x7d, 0xdc, 0x64, 0x6a, 0x06, 0xad, 0xad, 0x51, 0x01, 0xf7, 0x4a, 0x0e, 0x49, 0x8c, 0xc0, 0x1f, 0x00, 53 | 0x53, 0x2b, 0xac, 0x21, 0x78, 0x50, 0xbd, 0x90, 0x5e, 0x90, 0x92, 0x36, 0x56, 0xb7, 0xdf, 0xef, 0xef, 0x42, 0x48, 0x67, 0x67, 0xf3, 0x3e, 0xf6, 54 | 0x28, 0x3d, 0x4f, 0x42, 0x54, 0xab, 0x72, 0x58, 0x93, 0x90, 0xbe, 0xe5, 0x58, 0x08, 0xf1, 0xd6, 0x68, 0x08, 0x0d, 0x45, 0xd8, 0x93, 0xc2, 0xbc, 55 | 0xa2, 0xf7, 0x4d, 0x60, 0xa0, 0xc0, 0xd0, 0xa0, 0x99, 0x3c, 0xef, 0x01, 0x60, 0x47, 0x03, 0x33, 0x4c, 0x36, 0x38, 0x13, 0x94, 0x86, 0xbc, 0x9d, 56 | 0xaf, 0x24, 0xfd, 0x67, 0xa0, 0x7f, 0x9a, 0xd9, 0x43, 0x02, 0x03, 0x01, 0x00, 0x01, 0x3a, 0x12, 0x73, 0x74, 0x61, 0x67, 0x69, 0x6e, 0x67, 0x2e, 57 | 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x12, 0x80, 0x03, 0x98, 0x3e, 0x30, 0x35, 0x26, 0x75, 0xf4, 0x0b, 0xa7, 0x15, 0xfc, 58 | 0x24, 0x9b, 0xda, 0xe5, 0xd4, 0xac, 0x72, 0x49, 0xa2, 0x66, 0x65, 0x21, 0xe4, 0x36, 0x55, 0x73, 0x95, 0x29, 0x72, 0x1f, 0xf8, 0x80, 0xe0, 0xaa, 59 | 0xef, 0xc5, 0xe2, 0x7b, 0xc9, 0x80, 0xda, 0xea, 0xda, 0xbf, 0x3f, 0xc3, 0x86, 0xd0, 0x84, 0xa0, 0x2c, 0x82, 0x53, 0x78, 0x48, 0xcc, 0x75, 0x3f, 60 | 0xf4, 0x97, 0xb0, 0x11, 0xa7, 0xda, 0x97, 0x78, 0x8a, 0x00, 0xe2, 0xaa, 0x6b, 0x84, 0xcd, 0x7d, 0x71, 0xc0, 0x7a, 0x48, 0xeb, 0xf6, 0x16, 0x02, 61 | 0xcc, 0xa5, 0xa3, 0xf3, 0x20, 0x30, 0xa7, 0x29, 0x5c, 0x30, 0xda, 0x91, 0x5b, 0x91, 0xdc, 0x18, 0xb9, 0xbc, 0x95, 0x93, 0xb8, 0xde, 0x8b, 0xb5, 62 | 0x0f, 0x0d, 0xed, 0xc1, 0x29, 0x38, 0xb8, 0xe9, 0xe0, 0x39, 0xcd, 0xde, 0x18, 0xfa, 0x82, 0xe8, 0x1b, 0xb0, 0x32, 0x63, 0x0f, 0xe9, 0x55, 0xd8, 63 | 0x5a, 0x56, 0x6c, 0xe1, 0x54, 0x30, 0x0b, 0xf6, 0xd4, 0xc1, 0xbd, 0x12, 0x69, 0x66, 0x35, 0x6b, 0x28, 0x7d, 0x65, 0x7b, 0x18, 0xce, 0x63, 0xd0, 64 | 0xef, 0xd4, 0x5f, 0xc5, 0x26, 0x9e, 0x97, 0xea, 0xb1, 0x1c, 0xb5, 0x63, 0xe5, 0x56, 0x43, 0xb2, 0x6f, 0xf4, 0x9f, 0x10, 0x9c, 0x21, 0x01, 0xaf, 65 | 0xca, 0xf3, 0x5b, 0x83, 0x2f, 0x28, 0x8f, 0x0d, 0x9d, 0x45, 0x96, 0x0e, 0x25, 0x9e, 0x85, 0xfb, 0x5d, 0x24, 0xdb, 0xd2, 0xcf, 0x82, 0x76, 0x4c, 66 | 0x5d, 0xd9, 0xbf, 0x72, 0x7e, 0xfb, 0xe9, 0xc8, 0x61, 0xf8, 0x69, 0x32, 0x1f, 0x6a, 0xde, 0x18, 0x90, 0x5f, 0x4d, 0x92, 0xf9, 0xa6, 0xda, 0x65, 67 | 0x36, 0xdb, 0x84, 0x75, 0x87, 0x1d, 0x16, 0x8e, 0x87, 0x0b, 0xb2, 0x30, 0x3c, 0xf7, 0x0c, 0x6e, 0x97, 0x84, 0xc9, 0x3d, 0x2d, 0xe8, 0x45, 0xad, 68 | 0x82, 0x62, 0xbe, 0x7e, 0x0d, 0x4e, 0x2e, 0x4a, 0x07, 0x59, 0xce, 0xf8, 0x2d, 0x10, 0x9d, 0x25, 0x92, 0xc7, 0x24, 0x29, 0xf8, 0xc0, 0x17, 0x42, 69 | 0xba, 0xe2, 0xb3, 0xde, 0xca, 0xdb, 0xc3, 0x3c, 0x3e, 0x5f, 0x4b, 0xaf, 0x5e, 0x16, 0xec, 0xb7, 0x4e, 0xad, 0xba, 0xfc, 0xb7, 0xc6, 0x70, 0x5f, 70 | 0x7a, 0x9e, 0x3b, 0x6f, 0x39, 0x40, 0x38, 0x3f, 0x9c, 0x51, 0x16, 0xd2, 0x02, 0xa2, 0x0c, 0x92, 0x29, 0xee, 0x96, 0x9c, 0x25, 0x19, 0x71, 0x83, 71 | 0x03, 0xb5, 0x0d, 0x01, 0x30, 0xc3, 0x35, 0x2e, 0x06, 0xb0, 0x14, 0xd8, 0x38, 0x54, 0x0f, 0x8a, 0x0c, 0x22, 0x7c, 0x00, 0x11, 0xe0, 0xf5, 0xb3, 72 | 0x8e, 0x4e, 0x29, 0x8e, 0xd2, 0xcb, 0x30, 0x1e, 0xb4, 0x56, 0x49, 0x65, 0xf5, 0x5c, 0x5d, 0x79, 0x75, 0x7a, 0x25, 0x0a, 0x4e, 0xb9, 0xc8, 0x4a, 73 | 0xb3, 0xe6, 0x53, 0x9f, 0x6b, 0x6f, 0xdf, 0x56, 0x89, 0x9e, 0xa2, 0x99, 0x14 74 | ]) 75 | 76 | 77 | export class Session { 78 | constructor(contentDecryptionModule, pssh, initDataType = "cenc") { 79 | this._devicePrivateKey = forge.pki.privateKeyFromPem( 80 | contentDecryptionModule.privateKey 81 | ) 82 | this._identifierBlob = ClientIdentification.decode( 83 | contentDecryptionModule.identifierBlob 84 | ) 85 | if (typeof pssh === "string") { 86 | pssh = base64toUint8Array(pssh) 87 | } 88 | this._pssh = pssh 89 | this._webm = initDataType === "webm" 90 | } 91 | 92 | async setDefaultServiceCertificate() { 93 | await this.setServiceCertificate(STAGING_PRIVACY_CERT) 94 | } 95 | 96 | async setServiceCertificateFromMessage(rawSignedMessage) { 97 | const signedMessage = SignedMessage.decode(new Uint8Array(rawSignedMessage)) 98 | if (!signedMessage.msg) { 99 | throw new Error( 100 | "the service certificate message does not contain a message" 101 | ) 102 | } 103 | await this.setServiceCertificate(signedMessage.msg) 104 | } 105 | 106 | async setServiceCertificate(serviceCertificate) { 107 | const signedServiceCertificate = SignedDrmCertificate.decode( 108 | serviceCertificate 109 | ) 110 | if (!(await this._verifyServiceCertificate(signedServiceCertificate))) { 111 | throw new Error( 112 | "Service certificate is not signed by the Widevine root certificate" 113 | ) 114 | } 115 | this._serviceCertificate = signedServiceCertificate 116 | } 117 | 118 | createLicenseRequest(licenseType = LicenseType.STREAMING, android = false) { 119 | if (!this._webm) { 120 | if (compareUint8Arrays(this._pssh.subarray(4, 8), PSSH_MAGIC)) { 121 | if (compareUint8Arrays(this._pssh.subarray(12, 28), WIDEVINE_SYSTEM_ID)) { 122 | this._pssh = this._pssh.subarray(32) 123 | } else { 124 | throw new Error(`Invalid System ID in PSSH Box: ${uint8ArrayToHex(this._pssh.subarray(12, 28))}`) 125 | } 126 | } 127 | } 128 | 129 | const request_id = android ? this._generateAndroidIdentifier() : this._generateGenericIdentifier(); 130 | const content_id_data = {}; 131 | if (this._webm) { 132 | content_id_data.webmKeyId = new LicenseRequest.ContentIdentification.WebmKeyId({ 133 | header: this._pssh, 134 | licenseType: licenseType, 135 | requestId: request_id 136 | }); 137 | } else { 138 | content_id_data.widevinePsshData = new LicenseRequest.ContentIdentification.WidevinePsshData({ 139 | psshData: [this._pssh], 140 | licenseType: licenseType, 141 | requestId: request_id 142 | }); 143 | } 144 | const licenseRequest = new LicenseRequest({ 145 | type: LicenseRequest.RequestType.NEW, 146 | contentId: new LicenseRequest.ContentIdentification(content_id_data), 147 | requestTime: Math.floor(Date.now() / 1000), // BigInt(Date.now()) / BigInt(1000), 148 | protocolVersion: ProtocolVersion.VERSION_2_1, 149 | keyControlNonce: Math.floor(Math.random() * 2 ** 31) 150 | }) 151 | 152 | if (this._serviceCertificate) { 153 | licenseRequest.encryptedClientId = this._encryptClientIdentification( 154 | this._identifierBlob, 155 | this._serviceCertificate 156 | ) 157 | } else { 158 | licenseRequest.clientId = this._identifierBlob 159 | } 160 | 161 | this._rawLicenseRequest = LicenseRequest.encode(licenseRequest).finish() 162 | 163 | const pss = forge.pss.create({ 164 | md: forge.md.sha1.create(), 165 | mgf: forge.mgf.mgf1.create(forge.md.sha1.create()), 166 | saltLength: 20 167 | }) 168 | 169 | const md = forge.md.sha1.create() 170 | md.update(uint8ArrayToString(this._rawLicenseRequest), "raw") 171 | 172 | const signature = stringToUint8Array(this._devicePrivateKey.sign(md, pss)) 173 | const signedLicenseRequest = new SignedMessage({ 174 | type: SignedMessage.MessageType.LICENSE_REQUEST, 175 | msg: this._rawLicenseRequest, 176 | signature: signature 177 | }) 178 | 179 | return [SignedMessage.encode(signedLicenseRequest).finish(), request_id] 180 | } 181 | 182 | async parseLicense(rawLicense) { 183 | if (!this._rawLicenseRequest) { 184 | throw new Error("please request a license first") 185 | } 186 | 187 | const signedLicense = SignedMessage.decode(new Uint8Array(rawLicense)) 188 | if (!signedLicense.sessionKey) { 189 | throw new Error("the license does not contain a session key") 190 | } 191 | if (!signedLicense.msg) { 192 | throw new Error("the license does not contain a message") 193 | } 194 | if (!signedLicense.signature) { 195 | throw new Error("the license does not contain a signature") 196 | } 197 | 198 | const sessionKey = this._devicePrivateKey.decrypt( 199 | uint8ArrayToString(signedLicense.sessionKey), 200 | "RSA-OAEP", 201 | { 202 | md: forge.md.sha1.create() 203 | } 204 | ) 205 | 206 | const cmac = new AES_CMAC(stringToUint8Array(sessionKey)) 207 | const encKeyBase = new Uint8Array([ 208 | ...stringToUint8Array("ENCRYPTION"), 209 | ...new Uint8Array([0x00]), 210 | ...this._rawLicenseRequest, 211 | ...new Uint8Array([0x00, 0x00, 0x00, 0x80]) 212 | ]) 213 | const authKeyBase = new Uint8Array([ 214 | ...stringToUint8Array("AUTHENTICATION"), 215 | ...new Uint8Array([0x00]), 216 | ...this._rawLicenseRequest, 217 | ...new Uint8Array([0x00, 0x00, 0x02, 0x00]) 218 | ]) 219 | 220 | const encKey = await cmac.calculate( 221 | new Uint8Array([ 222 | ...new Uint8Array([0x01]), 223 | ...encKeyBase 224 | ]) 225 | ) 226 | 227 | const server_key_1 = await cmac.calculate(new Uint8Array([ 228 | ...new Uint8Array([0x01]), 229 | ...authKeyBase 230 | ])) 231 | const server_key_2 = await cmac.calculate(new Uint8Array([ 232 | ...new Uint8Array([0x02]), 233 | ...authKeyBase 234 | ])) 235 | const serverKey = new Uint8Array([ 236 | ...new Uint8Array(server_key_1), 237 | ...new Uint8Array(server_key_2) 238 | ]) 239 | 240 | const hmac = forge.hmac.create() 241 | hmac.start(forge.md.sha256.create(), uint8ArrayToString(serverKey), "raw") 242 | hmac.update(uint8ArrayToString(signedLicense.msg)) 243 | const calculatedSignature = stringToUint8Array(hmac.digest().data) 244 | 245 | if (!compareUint8Arrays(calculatedSignature, signedLicense.signature)) { 246 | throw new Error("signatures do not match") 247 | } 248 | 249 | const license = License.decode(signedLicense.msg) 250 | 251 | const keyContainers = license.key.map(keyContainer => { 252 | if (keyContainer.type && keyContainer.type === 2 && keyContainer.key && keyContainer.iv) { 253 | const keyBuffer = forge.util.createBuffer(encKey, 'raw'); 254 | const decipher = forge.cipher.createDecipher( 255 | "AES-CBC", 256 | keyBuffer 257 | ) 258 | 259 | decipher.start({ 260 | iv: uint8ArrayToString(keyContainer.iv) 261 | }) 262 | decipher.update(forge.util.createBuffer(keyContainer.key)) 263 | decipher.finish() 264 | 265 | return { 266 | kid: keyContainer.id.length !== 0 ? uint8ArrayToHex(keyContainer.id) : "00000000000000000000000000000000", 267 | k: stringToHex(decipher.output.data) 268 | } 269 | } 270 | }) 271 | const valid_containers = keyContainers.filter(container => !!container); 272 | if (valid_containers.length < 1) { 273 | throw new Error("there was not a single valid key in the response") 274 | } 275 | return valid_containers; 276 | } 277 | 278 | _encryptClientIdentification(clientIdentification, signedServiceCertificate) { 279 | if (!signedServiceCertificate.drmCertificate) { 280 | throw new Error( 281 | "the service certificate does not contain an actual certificate" 282 | ) 283 | } 284 | 285 | const serviceCertificate = DrmCertificate.decode( 286 | signedServiceCertificate.drmCertificate 287 | ) 288 | console.log("[Vineless]", "SERVICE_CERTIFICATE", serviceCertificate); 289 | 290 | if (!serviceCertificate.publicKey) { 291 | throw new Error("the service certificate does not contain a public key") 292 | } 293 | 294 | const key = forge.random.getBytesSync(16) 295 | const iv = forge.random.getBytesSync(16) 296 | 297 | const cipher = forge.cipher.createCipher("AES-CBC", key) 298 | cipher.start({ 299 | iv: iv 300 | }) 301 | cipher.update( 302 | forge.util.createBuffer( 303 | ClientIdentification.encode(clientIdentification).finish() 304 | ) 305 | ) 306 | cipher.finish() 307 | const rawEncryptedClientIdentification = stringToUint8Array(cipher.output.data) 308 | 309 | const publicKey = forge.pki.publicKeyFromAsn1( 310 | forge.asn1.fromDer( 311 | uint8ArrayToString(serviceCertificate.publicKey) 312 | ) 313 | ) 314 | const encryptedKey = publicKey.encrypt(key, "RSA-OAEP", { 315 | md: forge.md.sha1.create() 316 | }) 317 | 318 | return new EncryptedClientIdentification({ 319 | encryptedClientId: rawEncryptedClientIdentification, 320 | encryptedClientIdIv: stringToUint8Array(iv), 321 | encryptedPrivacyKey: stringToUint8Array(encryptedKey), 322 | providerId: serviceCertificate.providerId, 323 | serviceCertificateSerialNumber: serviceCertificate.serialNumber 324 | }) 325 | } 326 | 327 | async _verifyServiceCertificate(signedServiceCertificate) { 328 | if (!signedServiceCertificate.drmCertificate) { 329 | throw new Error( 330 | "the service certificate does not contain an actual certificate" 331 | ) 332 | } 333 | if (!signedServiceCertificate.signature) { 334 | throw new Error("the service certificate does not contain a signature") 335 | } 336 | 337 | const pss = forge.pss.create({ 338 | md: forge.md.sha1.create(), 339 | mgf: forge.mgf.mgf1.create(forge.md.sha1.create()), 340 | saltLength: 20 341 | }) 342 | 343 | const sha1 = forge.md.sha1.create() 344 | sha1.update( 345 | uint8ArrayToString(signedServiceCertificate.drmCertificate), 346 | "raw" 347 | ) 348 | 349 | const publicKey = forge.pki.publicKeyFromAsn1( 350 | forge.asn1.fromDer( 351 | uint8ArrayToString(WIDEVINE_ROOT_PUBLIC_KEY) 352 | ) 353 | ) 354 | 355 | return publicKey.verify( 356 | sha1.digest().bytes(), 357 | uint8ArrayToString(signedServiceCertificate.signature), 358 | pss 359 | ) 360 | } 361 | 362 | _parseWidevinePsshData(pssh) { 363 | try { 364 | return WidevinePsshData.decode(pssh) 365 | } catch (e) { 366 | console.warn("[Vineless]", "Failed to parse WidevinePsshData:", e); 367 | return null 368 | } 369 | } 370 | 371 | _generateAndroidIdentifier() { 372 | return stringToUint8Array(`${forge.util.bytesToHex(forge.random.getBytesSync(8))}0100000000000000`) 373 | } 374 | 375 | _generateGenericIdentifier() { 376 | return stringToUint8Array(forge.random.getBytesSync(16)) 377 | } 378 | 379 | static psshDataToPsshBoxB64(pssh_data) { 380 | const dataLength = pssh_data.length; 381 | const totalLength = dataLength + 32; 382 | const pssh = new Uint8Array([ 383 | ...intToUint8Array(totalLength), 384 | ...PSSH_MAGIC, 385 | ...new Uint8Array(4), 386 | ...WIDEVINE_SYSTEM_ID, 387 | ...intToUint8Array(dataLength), 388 | ...pssh_data 389 | ]); 390 | return uint8ArrayToBase64(pssh); 391 | } 392 | 393 | getPSSH() { 394 | if (this._webm) { 395 | return uint8ArrayToHex(this._pssh) 396 | } 397 | return Session.psshDataToPsshBoxB64(this._pssh) 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /src/lib/remote_cdm.js: -------------------------------------------------------------------------------- 1 | import { 2 | base64toUint8Array, 3 | uint8ArrayToBase64, 4 | uint8ArrayToHex, 5 | hexToUint8Array, 6 | compareUint8Arrays, 7 | flipUUIDByteOrder, 8 | RemoteCDMManager 9 | } from "../util.js"; 10 | 11 | export class GenericRemoteDevice { 12 | constructor(host, keySystem, sessionId, tab) { 13 | this.host = host; 14 | this.isPlayReady = keySystem.includes("playready"); 15 | } 16 | 17 | async generateChallenge(pssh, extra) { 18 | const { serverCert } = extra; 19 | 20 | if (!pssh) { 21 | throw new Error("No PSSH data in challenge"); 22 | } 23 | 24 | const selectedRemoteCdmName = await RemoteCDMManager[this.isPlayReady ? 'getSelectedPRRemoteCDM' : 'getSelectedRemoteCDM'](this.host); 25 | if (!selectedRemoteCdmName) { 26 | throw new Error("No Remote CDM selected"); 27 | } 28 | 29 | const selectedRemoteCdm = JSON.parse(await RemoteCDMManager.loadRemoteCDM(selectedRemoteCdmName)); 30 | selectedRemoteCdm.sg_api_conf = Object.assign(getDefaultSGConfig(selectedRemoteCdm.type), selectedRemoteCdm.sg_api_conf || {}); 31 | this.remoteCdm = new RemoteCdm(selectedRemoteCdm); 32 | 33 | this.pssh = pssh; 34 | if (this.isPlayReady) { 35 | const challengeData = base64toUint8Array(pssh); 36 | const challenge = new TextDecoder("utf-16le").decode(challengeData); 37 | 38 | this.wrmHeader = challenge.match(//gm)[0]; 39 | if (!this.remoteCdm.apiConf.prPsshAsIs) { 40 | this.pssh = this.wrmHeader; 41 | } 42 | } else { 43 | // Use hex-encoded raw key ID for WEBM when saving logs 44 | const psshBytes = base64toUint8Array(pssh); 45 | const PSSH_MAGIC = new Uint8Array([0x70, 0x73, 0x73, 0x68]); 46 | if (!compareUint8Arrays(psshBytes.subarray(4, 8), PSSH_MAGIC)) { 47 | this.hexPssh = uint8ArrayToHex(psshBytes); 48 | } 49 | } 50 | 51 | try { 52 | const challengeB64 = await this.remoteCdm.generateChallenge(this.pssh, serverCert); 53 | return challengeB64; 54 | } catch (error) { 55 | let message = error.message; 56 | if (this.remoteCdm.lastMsg) { 57 | message = "Server returned message: " + this.remoteCdm.lastMsg; 58 | } else if (message.includes("fetch")) { 59 | message += "\nMake sure the server is reachable."; 60 | } 61 | throw new Error("Remote: " + message); 62 | } 63 | } 64 | 65 | async parseLicense(license) { 66 | try { 67 | const keysData = await this.remoteCdm.parseLicense(license); 68 | 69 | if (!keysData) { 70 | throw new Error("No keys were received from the remote CDM!"); 71 | } 72 | 73 | const keys = this.remoteCdm.parseKeys(keysData); 74 | 75 | if (keys.length === 0) { 76 | throw new Error("No keys were received from the remote CDM!"); 77 | } 78 | 79 | return { 80 | type: this.remoteCdm.type, 81 | // Always prefer WRMHEADER here if available, to prevent duplicate log entries when switching PR local and remote 82 | pssh: this.wrmHeader || this.hexPssh || this.pssh, 83 | keys: keys 84 | }; 85 | } catch (error) { 86 | let message = error.message; 87 | if (this.remoteCdm.lastMsg) { 88 | message = "Server returned message: " + this.remoteCdm.lastMsg; 89 | } else if (message.includes("fetch")) { 90 | message += "\nMake sure the server is reachable."; 91 | } 92 | throw new Error("Remote: " + message); 93 | } 94 | } 95 | } 96 | 97 | export class RemoteCdm { 98 | constructor(dataObj) { 99 | this.type = dataObj.type || "WIDEVINE"; 100 | this.device_type = dataObj.device_type; 101 | this.system_id = dataObj.system_id; 102 | this.security_level = dataObj.security_level; 103 | this.device_name = dataObj.device_name || dataObj.name; 104 | this.name_override = dataObj.name_override; 105 | 106 | this.apiConf = dataObj.sg_api_conf; 107 | this.baseUrl = dataObj.host || ""; 108 | this.baseHeaders = this.apiConf?.headers || {}; 109 | 110 | this.overridingHeaders = this.apiConf?.overrideHeaders; 111 | if (this.overridingHeaders) { 112 | registerOverrideHeaders(this.overridingHeaders.headers, this.overridingHeaders.urls); 113 | } 114 | 115 | this.secret = dataObj.secret; 116 | for (const [key, value] of Object.entries(this.baseHeaders)) { 117 | if (value === "{secret}") { 118 | if (this.secret) { 119 | this.baseHeaders[key] = this.secret; 120 | } else { 121 | delete this.baseHeaders[key]; 122 | } 123 | } 124 | } 125 | } 126 | 127 | getName() { 128 | let name = this.name_override; 129 | if (!name) { 130 | name = this.baseUrl + "/" + this.device_name; 131 | } 132 | if (this.type === "PLAYREADY") { 133 | let type = "PR"; 134 | switch (this.security_level + '') { 135 | case "3000": 136 | type = "SL3K" 137 | break; 138 | case "2000": 139 | type = "SL2K" 140 | break; 141 | default: 142 | type = "SL" + this.security_level; 143 | } 144 | return `[${type}] ${name}`; 145 | } 146 | const type = this.device_type === "CHROME" ? "CHROME" : `L${this.security_level}`; 147 | return `[${type}] ${name} (${this.system_id})`; 148 | } 149 | 150 | async generateChallenge(pssh, serverCert) { 151 | this.pssh = pssh; 152 | this.serverCert = serverCert; 153 | 154 | const apiData = this.apiConf.generateChallenge; 155 | const requests = Array.isArray(apiData) ? apiData : [apiData]; 156 | for (const request of requests) { 157 | if (request.serverCertOnly && !serverCert) { 158 | continue; 159 | } 160 | const options = { 161 | method: request.method || "POST", 162 | headers: Object.assign(this.baseHeaders, request.headers), 163 | }; 164 | if (options.method === "POST") { 165 | let data = {}; 166 | if (request.bodyObj) { 167 | data = request.bodyObj; 168 | } 169 | if (request.sessionIdKeyName) { 170 | setNestedProperty(data, request.sessionIdKeyName, this.sessionId); 171 | } 172 | if (request.psshKeyName) { 173 | setNestedProperty(data, request.psshKeyName, this.pssh); 174 | } 175 | if (request.serverCertKeyName) { 176 | setNestedProperty(data, request.serverCertKeyName, this.serverCert); 177 | } 178 | options.body = JSON.stringify(data); 179 | } 180 | const res = await fetch(this.baseUrl + request.url.replaceAll("{device_name}", this.device_name).replace("%s", this.sessionId), options); 181 | const jsonData = await res.json(); 182 | const messageKey = request.messageKey || this.apiConf.messageKey; 183 | if (messageKey) { 184 | this.lastMsg = getNestedProperty(jsonData, messageKey); 185 | } 186 | if (request.sessionIdResKeyName) { 187 | this.sessionId = getNestedProperty(jsonData, request.sessionIdResKeyName); 188 | if (!this.sessionId) { 189 | throw new Error("Server did not return a session ID"); 190 | } 191 | } 192 | if (request.challengeKeyName) { 193 | this.challenge = getNestedProperty(jsonData, request.challengeKeyName); 194 | if (!this.challenge) { 195 | throw new Error("Server did not return a challenge"); 196 | } 197 | if (request.encodeB64) { 198 | this.challenge = btoa(this.challenge); 199 | } 200 | if (request.bundleInKeyMessage) { 201 | const newXmlDoc = ` 202 | 203 | ${this.challenge} 204 | 205 | 206 | Content-Type 207 | text/xml; charset=utf-8 208 | 209 | 210 | SOAPAction 211 | "http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense" 212 | 213 | 214 | 215 | `.replace(/ |\n/g, ''); 216 | 217 | const utf8KeyMessage = new TextEncoder().encode(newXmlDoc); 218 | const newKeyMessage = new Uint8Array(utf8KeyMessage.length * 2); 219 | 220 | for (let i = 0; i < utf8KeyMessage.length; i++) { 221 | newKeyMessage[i * 2] = utf8KeyMessage[i]; 222 | newKeyMessage[i * 2 + 1] = 0; 223 | } 224 | 225 | this.challenge = uint8ArrayToBase64(newKeyMessage); 226 | } 227 | } 228 | } 229 | return this.challenge; 230 | } 231 | 232 | async parseLicense(licenseB64) { 233 | const apiData = this.apiConf.parseLicense; 234 | const requests = Array.isArray(apiData) ? apiData : [apiData]; 235 | for (const request of requests) { 236 | const options = { 237 | method: request.method || "POST", 238 | headers: Object.assign(this.baseHeaders, request.headers), 239 | }; 240 | if (options.method === "POST") { 241 | let data = {}; 242 | if (request.bodyObj) { 243 | data = request.bodyObj; 244 | } 245 | if (request.sessionIdKeyName) { 246 | setNestedProperty(data, request.sessionIdKeyName, this.sessionId); 247 | } 248 | if (request.psshKeyName) { 249 | setNestedProperty(data, request.psshKeyName, this.pssh); 250 | } 251 | if (request.serverCertKeyName) { 252 | setNestedProperty(data, request.serverCertKeyName, this.serverCert); 253 | } 254 | if (request.challengeKeyName) { 255 | setNestedProperty(data, request.challengeKeyName, this.challenge); 256 | } 257 | if (request.licenseKeyName) { 258 | setNestedProperty(data, request.licenseKeyName, request.decodeB64 ? atob(licenseB64) : licenseB64); 259 | } 260 | options.body = JSON.stringify(data); 261 | } 262 | const res = await fetch(this.baseUrl + request.url.replaceAll("{device_name}", this.device_name).replace("%s", this.sessionId), options); 263 | const jsonData = await res.json(); 264 | const messageKey = request.messageKey || this.apiConf.messageKey; 265 | if (messageKey) { 266 | this.lastMsg = getNestedProperty(jsonData, messageKey); 267 | } 268 | if (request.sessionIdResKeyName) { 269 | this.sessionId = getNestedProperty(jsonData, request.sessionIdResKeyName); 270 | if (!this.sessionId) { 271 | throw new Error("Server did not return a session ID"); 272 | } 273 | } 274 | if (request.contentKeysKeyName) { 275 | this.contentKeys = getNestedProperty(jsonData, request.contentKeysKeyName); 276 | } 277 | } 278 | return this.contentKeys; 279 | } 280 | 281 | parseKeys(keysData) { 282 | const apiData = this.apiConf.keyParseRules; 283 | const keys = []; 284 | if (apiData.regex) { 285 | const regex = new RegExp(apiData.regex.data, 'g'); 286 | let match = null; 287 | do { 288 | let k, kid; 289 | match = regex.exec(keysData); 290 | if (match) { 291 | if (apiData.regex.keyFirst) { 292 | k = match[1]; 293 | kid = match[2]; 294 | } else { 295 | k = match[2]; 296 | kid = match[1]; 297 | } 298 | keys.push({ k, kid }); 299 | } 300 | } while (match); 301 | } else { 302 | const mainArray = getNestedProperty(keysData, apiData.mainArrayKeyName || []); 303 | for (const item of mainArray) { 304 | const k = getNestedProperty(item, apiData.keyKeyName); 305 | const kid = getNestedProperty(item, apiData.kidKeyName); 306 | keys.push({ k, kid }); 307 | } 308 | } 309 | if (apiData.base64) { 310 | for (const key of keys) { 311 | key.k = uint8ArrayToHex(base64toUint8Array(key.k)), 312 | key.kid = uint8ArrayToHex(base64toUint8Array(key.kid)) 313 | } 314 | } 315 | if (apiData.needsFlipping) { 316 | for (const key of keys) { 317 | key.k = uint8ArrayToHex(flipUUIDByteOrder(hexToUint8Array(key.k))); 318 | key.kid = uint8ArrayToHex(flipUUIDByteOrder(hexToUint8Array(key.kid))); 319 | } 320 | } 321 | return keys; 322 | } 323 | } 324 | 325 | function getNestedProperty(object, nestedKeyName) { 326 | const keyNames = Array.isArray(nestedKeyName) ? nestedKeyName : nestedKeyName.split('.'); 327 | 328 | if (keyNames.length === 0) { 329 | return object; 330 | } 331 | 332 | let value = object; 333 | for (const keyName of keyNames) { 334 | value = value[keyName]; 335 | if (!value) { 336 | return null; 337 | } 338 | } 339 | return value; 340 | } 341 | 342 | function setNestedProperty(object, nestedKeyName, value) { 343 | const keyNames = Array.isArray(nestedKeyName) ? nestedKeyName : nestedKeyName.split('.'); 344 | 345 | if (keyNames.length === 0) { 346 | return object; 347 | } 348 | 349 | let cur = object; 350 | for (let i = 0; i < keyNames.length - 1; i++) { 351 | const keyName = keyNames[i]; 352 | if (typeof cur[keyName] !== 'object' || cur[keyName] === null) { 353 | cur[keyName] = {}; 354 | } 355 | cur = cur[keyName]; 356 | } 357 | cur[keyNames[keyNames.length - 1]] = value; 358 | return object; 359 | } 360 | 361 | // Only for Firefox-based browsers cuz this ext is MV3 362 | function registerOverrideHeaders(overridingHeaders, urls) { 363 | try { 364 | const onBeforeSendHeaders = chrome.webRequest.onBeforeSendHeaders; 365 | 366 | if (registerOverrideHeaders._listener) { 367 | onBeforeSendHeaders.removeListener(registerOverrideHeaders._listener); 368 | delete registerOverrideHeaders._listener; 369 | } 370 | 371 | function overrideHeadersListener(details) { 372 | const requestHeaders = details.requestHeaders; 373 | for (const header in overridingHeaders) { 374 | const targetHeader = requestHeaders.find(h => h.name.toLowerCase() === header.toLowerCase()); 375 | if (targetHeader) { 376 | targetHeader.value = overridingHeaders[header]; 377 | } else { 378 | requestHeaders.push({ 379 | name: header, 380 | value: overridingHeaders[header] 381 | }); 382 | } 383 | } 384 | return { requestHeaders: requestHeaders }; 385 | } 386 | 387 | registerOverrideHeaders._listener = overrideHeadersListener; 388 | 389 | onBeforeSendHeaders.addListener( 390 | overrideHeadersListener, 391 | { urls: urls }, 392 | ["blocking", "requestHeaders"] 393 | ); 394 | } catch { 395 | // oh noes nerfed webRequest 396 | } 397 | } 398 | 399 | function getDefaultSGConfig(type) { 400 | if (type === "PLAYREADY") { 401 | return { 402 | "headers": { 403 | "Content-Type": "application/json", 404 | "X-Secret-Key": "{secret}" 405 | }, 406 | "generateChallenge": [ 407 | { 408 | "method": "GET", 409 | "url": "/{device_name}/open", 410 | "sessionIdResKeyName": "data.session_id" 411 | }, 412 | { 413 | "method": "POST", 414 | "url": "/{device_name}/get_license_challenge", 415 | "bodyObj": { 416 | "privacy_mode": true 417 | }, 418 | "sessionIdKeyName": "session_id", 419 | "psshKeyName": "init_data", 420 | "challengeKeyName": "data.challenge", 421 | "encodeB64": true, 422 | "bundleInKeyMessage": true 423 | } 424 | ], 425 | "parseLicense": [ 426 | { 427 | "method": "POST", 428 | "url": "/{device_name}/parse_license", 429 | "sessionIdKeyName": "session_id", 430 | "licenseKeyName": "license_message", 431 | "decodeB64": true 432 | }, 433 | { 434 | "method": "POST", 435 | "url": "/{device_name}/get_keys", 436 | "sessionIdKeyName": "session_id", 437 | "contentKeysKeyName": "data.keys" 438 | }, 439 | { 440 | "method": "GET", 441 | "url": "/{device_name}/close/%s" 442 | } 443 | ], 444 | "keyParseRules": { 445 | "keyKeyName": "key", 446 | "kidKeyName": "key_id" 447 | }, 448 | "messageKey": "message" 449 | } 450 | } else { 451 | return { 452 | "headers": { 453 | "Content-Type": "application/json", 454 | "X-Secret-Key": "{secret}" 455 | }, 456 | "generateChallenge": [ 457 | { 458 | "method": "GET", 459 | "url": "/{device_name}/open", 460 | "sessionIdResKeyName": "data.session_id" 461 | }, 462 | { 463 | "method": "POST", 464 | "url": "/{device_name}/set_service_certificate", 465 | "sessionIdKeyName": "session_id", 466 | "serverCertKeyName": "certificate", 467 | "serverCertOnly": true 468 | }, 469 | { 470 | "method": "POST", 471 | "url": "/{device_name}/get_license_challenge/AUTOMATIC", 472 | "bodyObj": { 473 | "privacy_mode": true 474 | }, 475 | "sessionIdKeyName": "session_id", 476 | "psshKeyName": "init_data", 477 | "challengeKeyName": "data.challenge_b64" 478 | } 479 | ], 480 | "parseLicense": [ 481 | { 482 | "method": "POST", 483 | "url": "/{device_name}/parse_license", 484 | "sessionIdKeyName": "session_id", 485 | "licenseKeyName": "license_message" 486 | }, 487 | { 488 | "method": "POST", 489 | "url": "/{device_name}/get_keys/CONTENT", 490 | "sessionIdKeyName": "session_id", 491 | "contentKeysKeyName": "data.keys" 492 | }, 493 | { 494 | "method": "GET", 495 | "url": "/{device_name}/close/%s" 496 | } 497 | ], 498 | "keyParseRules": { 499 | "keyKeyName": "key", 500 | "kidKeyName": "key_id" 501 | }, 502 | "messageKey": "message" 503 | } 504 | } 505 | } -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | import { 2 | base64toUint8Array, 3 | uint8ArrayToHex, 4 | setIcon, 5 | setBadgeText, 6 | openPopup, 7 | notifyUser, 8 | getWvPsshFromConcatPssh, 9 | SettingsManager, 10 | ScriptManager, 11 | AsyncLocalStorage, 12 | AsyncSessionStorage, 13 | } from "./util.js"; 14 | 15 | import { WidevineLocal } from "./lib/widevine/main.js"; 16 | import { PlayReadyLocal } from "./lib/playready/main.js"; 17 | import { GenericRemoteDevice } from "./lib/remote_cdm.js"; 18 | import { CustomHandlers } from "./lib/customhandlers/main.js"; 19 | 20 | let manifests = new Map(); 21 | let requests = new Map(); 22 | let sessions = new Map(); 23 | let sessionCnt = {}; 24 | 25 | const isSW = typeof window === "undefined"; 26 | 27 | chrome.webRequest.onBeforeSendHeaders.addListener( 28 | function(details) { 29 | if (details.method === "GET") { 30 | if (!requests.has(details.url)) { 31 | const headers = details.requestHeaders 32 | .filter(item => !( 33 | item.name.startsWith('sec-ch-ua') || 34 | item.name.startsWith('Sec-Fetch') || 35 | item.name.startsWith('Accept-') || 36 | item.name.startsWith('Host') || 37 | item.name === "Connection" 38 | )).reduce((acc, item) => { 39 | acc[item.name] = item.value; 40 | return acc; 41 | }, {}); 42 | console.debug(headers); 43 | requests.set(details.url, headers); 44 | } 45 | } 46 | }, 47 | {urls: [""]}, 48 | ['requestHeaders', chrome.webRequest.OnSendHeadersOptions.EXTRA_HEADERS].filter(Boolean) 49 | ); 50 | 51 | async function parseClearKey(body) { 52 | const clearkey = JSON.parse(atob(body)); 53 | 54 | const formatted_keys = clearkey["keys"].map(key => ({ 55 | ...key, 56 | kid: uint8ArrayToHex(base64toUint8Array(key.kid.replace(/-/g, "+").replace(/_/g, "/") + "==")), 57 | k: uint8ArrayToHex(base64toUint8Array(key.k.replace(/-/g, "+").replace(/_/g, "/") + "==")) 58 | })); 59 | const pssh = btoa(JSON.stringify({kids: clearkey["keys"].map(key => key.k)})); 60 | 61 | return { 62 | type: "CLEARKEY", 63 | pssh: pssh, 64 | keys: formatted_keys 65 | }; 66 | } 67 | 68 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 69 | (async () => { 70 | const tab_url = sender.tab ? sender.tab.url : null; 71 | const host = tab_url ? new URL(tab_url).host : null; 72 | const origin = sender.origin?.startsWith("https://") ? sender.origin : null; 73 | console.log(message.type, message.body); 74 | 75 | const profileConfig = await SettingsManager.getProfile(host); 76 | 77 | switch (message.type) { 78 | case "REQUEST": 79 | { 80 | if (!sessionCnt[sender.tab.id]) { 81 | sessionCnt[sender.tab.id] = 1; 82 | setIcon("images/icon-active.png", sender.tab.id); 83 | } else { 84 | sessionCnt[sender.tab.id]++; 85 | } 86 | 87 | if (!message.body) { 88 | setBadgeText("CK", sender.tab.id); 89 | sendResponse(); 90 | } else { 91 | const parsed = JSON.parse(message.body); 92 | const { keySystem, sessionId, initData, serverCert } = parsed; 93 | let device = null; 94 | let pssh = initData; 95 | const extra = {}; 96 | if (keySystem.includes("playready")) { 97 | setBadgeText("PR", sender.tab.id); 98 | const device_type = profileConfig.playready.type; 99 | switch (device_type) { 100 | case "local": 101 | device = PlayReadyLocal; 102 | break; 103 | case "remote": 104 | device = GenericRemoteDevice; 105 | break; 106 | case "custom": 107 | device = CustomHandlers[profileConfig.playready.device.custom].handler; 108 | break; 109 | } 110 | } else { 111 | setBadgeText("WV", sender.tab.id); 112 | extra.serverCert = serverCert; 113 | pssh = getWvPsshFromConcatPssh(pssh); 114 | const device_type = profileConfig.widevine.type; 115 | switch (device_type) { 116 | case "local": 117 | device = WidevineLocal; 118 | break; 119 | case "remote": 120 | device = GenericRemoteDevice; 121 | break; 122 | case "custom": 123 | device = CustomHandlers[profileConfig.widevine.device.custom].handler; 124 | break; 125 | } 126 | } 127 | 128 | if (device) { 129 | try { 130 | const instance = new device(host, keySystem, sessionId, sender.tab); 131 | const res = await instance.generateChallenge(pssh, extra); 132 | sessions.set(sessionId, instance); 133 | console.log("[Vineless] Generated license challenge:", res, "sessionId:", sessionId); 134 | if (!res || res === "null" || res === "bnVsbA==") { 135 | notifyUser( 136 | "Challenge generation failed!", 137 | "Please refer to the extension " + 138 | (isSW ? "service worker" : "background page") + 139 | " DevTools console/network tab for more details." 140 | ); 141 | sendResponse(); 142 | return; 143 | } 144 | sendResponse(res); 145 | } catch (error) { 146 | console.error("[Vineless] Challenge generation error:", error); 147 | notifyUser( 148 | "Challenge generation failed!", 149 | error.message + 150 | "\nSee extension DevTools for details.", // Reserve space for long error messages 151 | true 152 | ); 153 | sendResponse(); 154 | } 155 | } else { 156 | notifyUser("Challenge generation failed!", "No device handler was selected"); 157 | sendResponse(); 158 | } 159 | } 160 | break; 161 | } 162 | 163 | case "RESPONSE": 164 | { 165 | const parsed = JSON.parse(message.body); 166 | const { keySystem, sessionId, license, persistent } = parsed; 167 | let res = null; 168 | if (keySystem === "org.w3.clearkey") { 169 | res = await parseClearKey(license); 170 | } else { 171 | if (sessionId) { 172 | const device = sessions.get(sessionId); 173 | if (device) { 174 | try { 175 | res = await device.parseLicense(license); 176 | sessions.delete(sessionId); 177 | } catch (error) { 178 | console.error("[Vineless] License parsing error:", error); 179 | notifyUser( 180 | "License parsing failed!", 181 | error.message + 182 | "\nSee extension DevTools for details.", // Reserve space for long error messages 183 | true 184 | ); 185 | } 186 | } else { 187 | console.error("[Vineless] No device found for session:", sessionId); 188 | notifyUser("License parsing failed!", "No saved device handler found for session " + sessionId, true); 189 | } 190 | } 191 | } 192 | 193 | if (res) { 194 | console.log("[Vineless]", "KEYS", JSON.stringify(res.keys), tab_url); 195 | 196 | const storage = sender.tab?.incognito ? AsyncSessionStorage : AsyncLocalStorage; 197 | // Find existing PSSH with the same KID for WebM initData to prevent duplicate log entries 198 | if (/^[0-9a-fA-F]{32}$/.test(res.pssh)) { 199 | // Find first log that contains the requested KID 200 | const logs = Object.values(await AsyncLocalStorage.getStorage()); 201 | const log = logs.find(log => 202 | log.origin === origin && log.type === "WIDEVINE" && log.keys.some(k => k.kid.toLowerCase() === res.pssh.toLowerCase()) 203 | ); 204 | if (log) { 205 | res.pssh = log.pssh; 206 | } 207 | } 208 | const key = res.pssh + origin; 209 | const existing = (await storage.getStorage(key))?.[key]; 210 | if (existing) { 211 | if (persistent && profileConfig.allowPersistence && origin !== null) { 212 | if (existing.sessions) { 213 | existing.sessions.push(sessionId); 214 | } else { 215 | existing.sessions = [sessionId]; 216 | } 217 | } 218 | existing.url = tab_url; 219 | existing.keys = res.keys; 220 | existing.manifests = manifests.has(tab_url) ? manifests.get(tab_url) : []; 221 | existing.title = sender.tab?.title; 222 | existing.timestamp = Math.floor(Date.now() / 1000); 223 | await storage.setStorage({ [key]: existing }); 224 | } else { 225 | res.url = tab_url; 226 | res.origin = origin; 227 | res.manifests = manifests.has(tab_url) ? manifests.get(tab_url) : []; 228 | res.title = sender.tab?.title; 229 | res.timestamp = Math.floor(Date.now() / 1000); 230 | 231 | if (persistent && profileConfig.allowPersistence && origin !== null) { 232 | res.sessions = [sessionId]; 233 | } 234 | 235 | await storage.setStorage({ [key]: res }); 236 | } 237 | 238 | sendResponse(JSON.stringify({ 239 | pssh: res.pssh, 240 | keys: res.keys 241 | })); 242 | } else { 243 | // Most likely exception thrown in device.parseLicense, which is already notified above 244 | sendResponse(); 245 | } 246 | break; 247 | } 248 | case "LOAD": 249 | { 250 | if (origin === null) { 251 | sendResponse(); 252 | notifyUser("Vineless", "Persistent license usage has been blocked on a page with opaque origin."); 253 | return; 254 | } 255 | if (sender.tab?.incognito) { 256 | notifyUser("Vineless", "Persistent license usage has been blocked in incognito mode."); 257 | return; 258 | } 259 | 260 | if (!sessionCnt[sender.tab.id]) { 261 | sessionCnt[sender.tab.id] = 1; 262 | setIcon("images/icon-active.png", sender.tab.id); 263 | } else { 264 | sessionCnt[sender.tab.id]++; 265 | } 266 | 267 | const parsed = JSON.parse(message.body); 268 | const { keySystem, sessionId } = parsed; 269 | if (keySystem === "org.w3.clearkey") { 270 | setBadgeText("CK", sender.tab.id); 271 | } else if (keySystem.includes("playready")) { 272 | setBadgeText("PR", sender.tab.id); 273 | } else if (keySystem.includes("widevine")) { 274 | setBadgeText("WV", sender.tab.id); 275 | } 276 | 277 | const logs = Object.values(await AsyncLocalStorage.getStorage()); 278 | const log = logs.find(log => log.origin === origin && log.sessions?.includes(sessionId)); 279 | if (log) { 280 | sendResponse(JSON.stringify({ 281 | pssh: log.pssh, 282 | keys: log.keys 283 | })); 284 | } else { 285 | sendResponse(); 286 | notifyUser("Persistent session not found", "Web page tried to load a persistent session that does not exist."); 287 | } 288 | break; 289 | } 290 | case "REMOVE": 291 | { 292 | if (origin === null) { 293 | sendResponse(); 294 | notifyUser("Vineless", "Persistent license usage has been blocked on a page with opaque origin."); 295 | return; 296 | } 297 | if (sender.tab?.incognito) { 298 | notifyUser("Vineless", "Persistent license usage has been blocked in incognito mode."); 299 | return; 300 | } 301 | 302 | const sessionId = message.body; 303 | const logs = Object.values(await AsyncLocalStorage.getStorage()); 304 | const log = logs.find(log => log.origin === origin && log.sessions?.includes(sessionId)); 305 | if (log) { 306 | const idx = log.sessions.indexOf(sessionId); 307 | log.sessions.splice(idx, 1); 308 | await AsyncLocalStorage.setStorage({ [log.pssh + origin]: log }); 309 | } 310 | sendResponse(); 311 | break; 312 | } 313 | case "CLOSE": 314 | if (sender?.tab?.id) { 315 | if (sessionCnt[sender.tab.id]) { 316 | if (--sessionCnt[sender.tab.id] === 0) { 317 | setIcon("images/icon-closed.png", sender.tab.id); 318 | setBadgeText("-", sender.tab.id); 319 | } 320 | } 321 | } 322 | sendResponse(); 323 | break; 324 | case "GET_ACTIVE": 325 | if (message.from === "content" || sender.tab) return; 326 | sendResponse(sessionCnt[message.body]); 327 | break; 328 | case "GET_PROFILE": 329 | let wvEnabled = profileConfig.widevine.enabled; 330 | if (wvEnabled) { 331 | switch (profileConfig.widevine.type) { 332 | case "local": 333 | if (!profileConfig.widevine.device.local) { 334 | wvEnabled = false; 335 | } 336 | break; 337 | case "remote": 338 | if (!profileConfig.widevine.device.remote) { 339 | wvEnabled = false; 340 | } 341 | break; 342 | case "custom": 343 | if (!profileConfig.widevine.device.custom || !CustomHandlers[profileConfig.widevine.device.custom]?.handler) { 344 | wvEnabled = false; 345 | } 346 | break; 347 | } 348 | } 349 | let prEnabled = profileConfig.playready.enabled; 350 | if (prEnabled) { 351 | switch (profileConfig.playready.type) { 352 | case "local": 353 | if (!profileConfig.playready.device.local) { 354 | prEnabled = false; 355 | } 356 | break; 357 | case "remote": 358 | if (!profileConfig.playready.device.remote) { 359 | prEnabled = false; 360 | } 361 | break; 362 | case "custom": 363 | if (!profileConfig.playready.device.custom || !CustomHandlers[profileConfig.playready.device.custom]?.handler) { 364 | prEnabled = false; 365 | } 366 | break; 367 | } 368 | } 369 | sendResponse(JSON.stringify({ 370 | enabled: profileConfig.enabled, 371 | widevine: { 372 | enabled: wvEnabled, 373 | serverCert: profileConfig.widevine.serverCert, 374 | robustness: profileConfig.widevine.robustness 375 | }, 376 | playready: { 377 | enabled: prEnabled, 378 | allowSL3K: profileConfig.playready.allowSL3K !== false 379 | }, 380 | clearkey: { 381 | enabled: profileConfig.clearkey.enabled 382 | }, 383 | hdcp: profileConfig.hdcp ?? 9, 384 | blockDisabled: profileConfig.blockDisabled, 385 | allowPersistence: profileConfig.allowPersistence && origin !== null && !sender.tab?.incognito, 386 | })); 387 | break; 388 | case "OPEN_PICKER_WVD": 389 | if (message.from === "content" || sender.tab) return; 390 | openPopup('pages/picker/filePicker.html?type=wvd', 450, 200); 391 | break; 392 | case "OPEN_PICKER_REMOTE": 393 | if (message.from === "content" || sender.tab) return; 394 | openPopup('pages/picker/filePicker.html?type=remote', 450, 200); 395 | break; 396 | case "OPEN_PICKER_PRD": 397 | if (message.from === "content" || sender.tab) return; 398 | openPopup('pages/picker/filePicker.html?type=prd', 450, 200); 399 | break; 400 | case "MANIFEST": 401 | const parsed = JSON.parse(message.body); 402 | const element = { 403 | type: parsed.type, 404 | url: parsed.url, 405 | headers: requests.has(parsed.url) ? requests.get(parsed.url) : [], 406 | }; 407 | 408 | if (!manifests.has(tab_url)) { 409 | manifests.set(tab_url, [element]); 410 | } else { 411 | let elements = manifests.get(tab_url); 412 | if (!elements.some(e => e.url === parsed.url)) { 413 | elements.push(element); 414 | manifests.set(tab_url, elements); 415 | } 416 | } 417 | sendResponse(); 418 | } 419 | })(); 420 | return true; 421 | }); 422 | 423 | chrome.webNavigation.onCommitted.addListener((details) => { 424 | if (details.frameId === 0) { // main frame only 425 | delete sessionCnt[details.tabId]; 426 | } 427 | }); 428 | 429 | chrome.tabs.onRemoved.addListener((tabId) => { 430 | delete sessionCnt[tabId]; 431 | }); 432 | 433 | chrome.windows.onRemoved.addListener(() => { 434 | chrome.windows.getAll({ populate: false }, (windows) => { 435 | const incognitoWindows = windows.filter(w => w.incognito); 436 | if (incognitoWindows.length === 0) { 437 | chrome.storage.session.clear(); 438 | } 439 | }); 440 | }); 441 | 442 | SettingsManager.getGlobalEnabled().then(enabled => { 443 | if (!enabled) { 444 | setIcon("images/icon-disabled.png"); 445 | ScriptManager.unregisterContentScript(); 446 | } else { 447 | ScriptManager.registerContentScript(); 448 | } 449 | }); 450 | 451 | self.addEventListener('error', (event) => { 452 | notifyUser( 453 | "An unknown error occurred!", 454 | (event.message || event.error) + 455 | "\nRefer to the extension " + 456 | (isSW ? "service worker" : "background page") + 457 | " DevTools console for more details.", 458 | true 459 | ); 460 | }); 461 | self.addEventListener('unhandledrejection', (event) => { 462 | notifyUser( 463 | "An unknown error occurred!", 464 | (event.reason) + 465 | "\nRefer to the extension " + 466 | (isSW ? "service worker" : "background page") + 467 | " DevTools console for more details.", 468 | true 469 | ); 470 | }); -------------------------------------------------------------------------------- /src/lib/widevine/protobuf.min.js: -------------------------------------------------------------------------------- 1 | var commonjsGlobal="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{},indexMinimal={},minimal$1={},aspromise,hasRequiredAspromise;function requireAspromise(){if(hasRequiredAspromise)return aspromise;return hasRequiredAspromise=1,aspromise=function(t,e){var r=new Array(arguments.length-1),i=0,n=2,o=!0;for(;n1&&"="===t.charAt(e);)++r;return Math.ceil(3*t.length)/4-r};for(var r=new Array(64),i=new Array(123),n=0;n<64;)i[r[n]=n<26?n+65:n<52?n+71:n<62?n-4:n-59|43]=n++;e.encode=function(t,e,i){for(var n,o=null,u=[],s=0,f=0;e>2],n=(3&a)<<4,f=1;break;case 1:u[s++]=r[n|a>>4],n=(15&a)<<2,f=2;break;case 2:u[s++]=r[n|a>>6],u[s++]=r[63&a],f=0}s>8191&&((o||(o=[])).push(String.fromCharCode.apply(String,u)),s=0)}return f&&(u[s++]=r[n],u[s++]=61,1===f&&(u[s++]=61)),o?(s&&o.push(String.fromCharCode.apply(String,u.slice(0,s))),o.join("")):String.fromCharCode.apply(String,u.slice(0,s))};var o="invalid encoding";e.decode=function(t,e,r){for(var n,u=r,s=0,f=0;f1)break;if(void 0===(a=i[a]))throw Error(o);switch(s){case 0:n=a,s=1;break;case 1:e[r++]=n<<2|(48&a)>>4,n=a,s=2;break;case 2:e[r++]=(15&n)<<4|(60&a)>>2,n=a,s=3;break;case 3:e[r++]=(3&n)<<6|a,s=0}}if(1===s)throw Error(o);return r-u},e.test=function(t){return/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(t)}}(base64)),base64}function requireEventemitter(){if(hasRequiredEventemitter)return eventemitter;function t(){this._listeners={}}return hasRequiredEventemitter=1,eventemitter=t,t.prototype.on=function(t,e,r){return(this._listeners[t]||(this._listeners[t]=[])).push({fn:e,ctx:r||this}),this},t.prototype.off=function(t,e){if(void 0===t)this._listeners={};else if(void 0===e)this._listeners[t]=[];else for(var r=this._listeners[t],i=0;i0?0:2147483648,r,i);else if(isNaN(e))t(2143289344,r,i);else if(e>34028234663852886e22)t((n<<31|2139095040)>>>0,r,i);else if(e<11754943508222875e-54)t((n<<31|Math.round(e/1401298464324817e-60))>>>0,r,i);else{var o=Math.floor(Math.log(e)/Math.LN2);t((n<<31|o+127<<23|8388607&Math.round(e*Math.pow(2,-o)*8388608))>>>0,r,i)}}function u(t,e,r){var i=t(e,r),n=2*(i>>31)+1,o=i>>>23&255,u=8388607&i;return 255===o?u?NaN:n*(1/0):0===o?1401298464324817e-60*n*u:n*Math.pow(2,o-150)*(u+8388608)}t.writeFloatLE=o.bind(null,e),t.writeFloatBE=o.bind(null,r),t.readFloatLE=u.bind(null,i),t.readFloatBE=u.bind(null,n)}(),"undefined"!=typeof Float64Array?function(){var e=new Float64Array([-0]),r=new Uint8Array(e.buffer),i=128===r[7];function n(t,i,n){e[0]=t,i[n]=r[0],i[n+1]=r[1],i[n+2]=r[2],i[n+3]=r[3],i[n+4]=r[4],i[n+5]=r[5],i[n+6]=r[6],i[n+7]=r[7]}function o(t,i,n){e[0]=t,i[n]=r[7],i[n+1]=r[6],i[n+2]=r[5],i[n+3]=r[4],i[n+4]=r[3],i[n+5]=r[2],i[n+6]=r[1],i[n+7]=r[0]}function u(t,i){return r[0]=t[i],r[1]=t[i+1],r[2]=t[i+2],r[3]=t[i+3],r[4]=t[i+4],r[5]=t[i+5],r[6]=t[i+6],r[7]=t[i+7],e[0]}function s(t,i){return r[7]=t[i],r[6]=t[i+1],r[5]=t[i+2],r[4]=t[i+3],r[3]=t[i+4],r[2]=t[i+5],r[1]=t[i+6],r[0]=t[i+7],e[0]}t.writeDoubleLE=i?n:o,t.writeDoubleBE=i?o:n,t.readDoubleLE=i?u:s,t.readDoubleBE=i?s:u}():function(){function o(t,e,r,i,n,o){var u=i<0?1:0;if(u&&(i=-i),0===i)t(0,n,o+e),t(1/i>0?0:2147483648,n,o+r);else if(isNaN(i))t(0,n,o+e),t(2146959360,n,o+r);else if(i>17976931348623157e292)t(0,n,o+e),t((u<<31|2146435072)>>>0,n,o+r);else{var s;if(i<22250738585072014e-324)t((s=i/5e-324)>>>0,n,o+e),t((u<<31|s/4294967296)>>>0,n,o+r);else{var f=Math.floor(Math.log(i)/Math.LN2);1024===f&&(f=1023),t(4503599627370496*(s=i*Math.pow(2,-f))>>>0,n,o+e),t((u<<31|f+1023<<20|1048576*s&1048575)>>>0,n,o+r)}}}function u(t,e,r,i,n){var o=t(i,n+e),u=t(i,n+r),s=2*(u>>31)+1,f=u>>>20&2047,a=4294967296*(1048575&u)+o;return 2047===f?a?NaN:s*(1/0):0===f?5e-324*s*a:s*Math.pow(2,f-1075)*(a+4503599627370496)}t.writeDoubleLE=o.bind(null,e,0,4),t.writeDoubleBE=o.bind(null,r,4,0),t.readDoubleLE=u.bind(null,i,0,4),t.readDoubleBE=u.bind(null,n,4,0)}(),t}function e(t,e,r){e[r]=255&t,e[r+1]=t>>>8&255,e[r+2]=t>>>16&255,e[r+3]=t>>>24}function r(t,e,r){e[r]=t>>>24,e[r+1]=t>>>16&255,e[r+2]=t>>>8&255,e[r+3]=255&t}function i(t,e){return(t[e]|t[e+1]<<8|t[e+2]<<16|t[e+3]<<24)>>>0}function n(t,e){return(t[e]<<24|t[e+1]<<16|t[e+2]<<8|t[e+3])>>>0}return hasRequiredFloat=1,float=t(t)}function requireInquire(){if(hasRequiredInquire)return inquire_1;function inquire(moduleName){try{var mod=eval("quire".replace(/^/,"re"))(moduleName);if(mod&&(mod.length||Object.keys(mod).length))return mod}catch(t){}return null}return hasRequiredInquire=1,inquire_1=inquire,inquire_1}var utf8={},hasRequiredUtf8,pool_1,hasRequiredPool,longbits,hasRequiredLongbits,hasRequiredMinimal$1,writer,hasRequiredWriter,writer_buffer,hasRequiredWriter_buffer,reader,hasRequiredReader,reader_buffer,hasRequiredReader_buffer;function requireUtf8(){return hasRequiredUtf8||(hasRequiredUtf8=1,function(t){var e=t;e.length=function(t){for(var e=0,r=0,i=0;i191&&i<224?o[u++]=(31&i)<<6|63&t[e++]:i>239&&i<365?(i=((7&i)<<18|(63&t[e++])<<12|(63&t[e++])<<6|63&t[e++])-65536,o[u++]=55296+(i>>10),o[u++]=56320+(1023&i)):o[u++]=(15&i)<<12|(63&t[e++])<<6|63&t[e++],u>8191&&((n||(n=[])).push(String.fromCharCode.apply(String,o)),u=0);return n?(u&&n.push(String.fromCharCode.apply(String,o.slice(0,u))),n.join("")):String.fromCharCode.apply(String,o.slice(0,u))},e.write=function(t,e,r){for(var i,n,o=r,u=0;u>6|192,e[r++]=63&i|128):55296==(64512&i)&&56320==(64512&(n=t.charCodeAt(u+1)))?(i=65536+((1023&i)<<10)+(1023&n),++u,e[r++]=i>>18|240,e[r++]=i>>12&63|128,e[r++]=i>>6&63|128,e[r++]=63&i|128):(e[r++]=i>>12|224,e[r++]=i>>6&63|128,e[r++]=63&i|128);return r-o}}(utf8)),utf8}function requirePool(){if(hasRequiredPool)return pool_1;return hasRequiredPool=1,pool_1=function(t,e,r){var i=r||8192,n=i>>>1,o=null,u=i;return function(r){if(r<1||r>n)return t(r);u+r>i&&(o=t(i),u=0);var s=e.call(o,u,u+=r);return 7&u&&(u=1+(7|u)),s}}}function requireLongbits(){if(hasRequiredLongbits)return longbits;hasRequiredLongbits=1,longbits=e;var t=requireMinimal$1();function e(t,e){this.lo=t>>>0,this.hi=e>>>0}var r=e.zero=new e(0,0);r.toNumber=function(){return 0},r.zzEncode=r.zzDecode=function(){return this},r.length=function(){return 1};var i=e.zeroHash="\0\0\0\0\0\0\0\0";e.fromNumber=function(t){if(0===t)return r;var i=t<0;i&&(t=-t);var n=t>>>0,o=(t-n)/4294967296>>>0;return i&&(o=~o>>>0,n=~n>>>0,++n>4294967295&&(n=0,++o>4294967295&&(o=0))),new e(n,o)},e.from=function(i){if("number"==typeof i)return e.fromNumber(i);if(t.isString(i)){if(!t.Long)return e.fromNumber(parseInt(i,10));i=t.Long.fromString(i)}return i.low||i.high?new e(i.low>>>0,i.high>>>0):r},e.prototype.toNumber=function(t){if(!t&&this.hi>>>31){var e=1+~this.lo>>>0,r=~this.hi>>>0;return e||(r=r+1>>>0),-(e+4294967296*r)}return this.lo+4294967296*this.hi},e.prototype.toLong=function(e){return t.Long?new t.Long(0|this.lo,0|this.hi,Boolean(e)):{low:0|this.lo,high:0|this.hi,unsigned:Boolean(e)}};var n=String.prototype.charCodeAt;return e.fromHash=function(t){return t===i?r:new e((n.call(t,0)|n.call(t,1)<<8|n.call(t,2)<<16|n.call(t,3)<<24)>>>0,(n.call(t,4)|n.call(t,5)<<8|n.call(t,6)<<16|n.call(t,7)<<24)>>>0)},e.prototype.toHash=function(){return String.fromCharCode(255&this.lo,this.lo>>>8&255,this.lo>>>16&255,this.lo>>>24,255&this.hi,this.hi>>>8&255,this.hi>>>16&255,this.hi>>>24)},e.prototype.zzEncode=function(){var t=this.hi>>31;return this.hi=((this.hi<<1|this.lo>>>31)^t)>>>0,this.lo=(this.lo<<1^t)>>>0,this},e.prototype.zzDecode=function(){var t=-(1&this.lo);return this.lo=((this.lo>>>1|this.hi<<31)^t)>>>0,this.hi=(this.hi>>>1^t)>>>0,this},e.prototype.length=function(){var t=this.lo,e=(this.lo>>>28|this.hi<<4)>>>0,r=this.hi>>>24;return 0===r?0===e?t<16384?t<128?1:2:t<2097152?3:4:e<16384?e<128?5:6:e<2097152?7:8:r<128?9:10},longbits}function requireMinimal$1(){return hasRequiredMinimal$1||(hasRequiredMinimal$1=1,function(){var t=minimal$1;function e(t,e,r){for(var i=Object.keys(e),n=0;n0)},t.Buffer=function(){try{var e=t.inquire("buffer").Buffer;return e.prototype.utf8Write?e:null}catch(t){return null}}(),t._Buffer_from=null,t._Buffer_allocUnsafe=null,t.newBuffer=function(e){return"number"==typeof e?t.Buffer?t._Buffer_allocUnsafe(e):new t.Array(e):t.Buffer?t._Buffer_from(e):"undefined"==typeof Uint8Array?e:new Uint8Array(e)},t.Array="undefined"!=typeof Uint8Array?Uint8Array:Array,t.Long=t.global.dcodeIO&&t.global.dcodeIO.Long||t.global.Long||t.inquire("long"),t.key2Re=/^true|false|0|1$/,t.key32Re=/^-?(?:0|[1-9][0-9]*)$/,t.key64Re=/^(?:[\\x00-\\xff]{8}|-?(?:0|[1-9][0-9]*))$/,t.longToHash=function(e){return e?t.LongBits.from(e).toHash():t.LongBits.zeroHash},t.longFromHash=function(e,r){var i=t.LongBits.fromHash(e);return t.Long?t.Long.fromBits(i.lo,i.hi,r):i.toNumber(Boolean(r))},t.merge=e,t.lcFirst=function(t){return t.charAt(0).toLowerCase()+t.substring(1)},t.newError=r,t.ProtocolError=r("ProtocolError"),t.oneOfGetter=function(t){for(var e={},r=0;r-1;--r)if(1===e[t[r]]&&void 0!==this[t[r]]&&null!==this[t[r]])return t[r]}},t.oneOfSetter=function(t){return function(e){for(var r=0;r>>7|t.hi<<25)>>>0,t.hi>>>=7;for(;t.lo>127;)e[r++]=127&t.lo|128,t.lo=t.lo>>>7;e[r++]=t.lo}function p(t,e,r){e[r]=255&t,e[r+1]=t>>>8&255,e[r+2]=t>>>16&255,e[r+3]=t>>>24}f.create=a(),f.alloc=function(t){return new e.Array(t)},e.Array!==Array&&(f.alloc=e.pool(f.alloc,e.Array.prototype.subarray)),f.prototype._push=function(t,e,r){return this.tail=this.tail.next=new o(t,e,r),this.len+=e,this},l.prototype=Object.create(o.prototype),l.prototype.fn=function(t,e,r){for(;t>127;)e[r++]=127&t|128,t>>>=7;e[r]=t},f.prototype.uint32=function(t){return this.len+=(this.tail=this.tail.next=new l((t>>>=0)<128?1:t<16384?2:t<2097152?3:t<268435456?4:5,t)).len,this},f.prototype.int32=function(t){return t<0?this._push(c,10,r.fromNumber(t)):this.uint32(t)},f.prototype.sint32=function(t){return this.uint32((t<<1^t>>31)>>>0)},f.prototype.uint64=function(t){var e=r.from(t);return this._push(c,e.length(),e)},f.prototype.int64=f.prototype.uint64,f.prototype.sint64=function(t){var e=r.from(t).zzEncode();return this._push(c,e.length(),e)},f.prototype.bool=function(t){return this._push(h,1,t?1:0)},f.prototype.fixed32=function(t){return this._push(p,4,t>>>0)},f.prototype.sfixed32=f.prototype.fixed32,f.prototype.fixed64=function(t){var e=r.from(t);return this._push(p,4,e.lo)._push(p,4,e.hi)},f.prototype.sfixed64=f.prototype.fixed64,f.prototype.float=function(t){return this._push(e.float.writeFloatLE,4,t)},f.prototype.double=function(t){return this._push(e.float.writeDoubleLE,8,t)};var d=e.Array.prototype.set?function(t,e,r){e.set(t,r)}:function(t,e,r){for(var i=0;i>>0;if(!r)return this._push(h,1,0);if(e.isString(t)){var n=f.alloc(r=i.length(t));i.decode(t,n,0),t=n}return this.uint32(r)._push(d,r,t)},f.prototype.string=function(t){var e=n.length(t);return e?this.uint32(e)._push(n.write,e,t):this._push(h,1,0)},f.prototype.fork=function(){return this.states=new s(this),this.head=this.tail=new o(u,0,0),this.len=0,this},f.prototype.reset=function(){return this.states?(this.head=this.states.head,this.tail=this.states.tail,this.len=this.states.len,this.states=this.states.next):(this.head=this.tail=new o(u,0,0),this.len=0),this},f.prototype.ldelim=function(){var t=this.head,e=this.tail,r=this.len;return this.reset().uint32(r),r&&(this.tail.next=t.next,this.tail=e,this.len+=r),this},f.prototype.finish=function(){for(var t=this.head.next,e=this.constructor.alloc(this.len),r=0;t;)t.fn(t.val,e,r),r+=t.len,t=t.next;return e},f._configure=function(e){t=e,f.create=a(),t._configure()},writer}function requireWriter_buffer(){if(hasRequiredWriter_buffer)return writer_buffer;hasRequiredWriter_buffer=1,writer_buffer=r;var t=requireWriter();(r.prototype=Object.create(t.prototype)).constructor=r;var e=requireMinimal$1();function r(){t.call(this)}function i(t,r,i){t.length<40?e.utf8.write(t,r,i):r.utf8Write?r.utf8Write(t,i):r.write(t,i)}return r._configure=function(){r.alloc=e._Buffer_allocUnsafe,r.writeBytesBuffer=e.Buffer&&e.Buffer.prototype instanceof Uint8Array&&"set"===e.Buffer.prototype.set.name?function(t,e,r){e.set(t,r)}:function(t,e,r){if(t.copy)t.copy(e,r,0,t.length);else for(var i=0;i>>0;return this.uint32(i),i&&this._push(r.writeBytesBuffer,i,t),this},r.prototype.string=function(t){var r=e.Buffer.byteLength(t);return this.uint32(r),r&&this._push(i,r,t),this},r._configure(),writer_buffer}function requireReader(){if(hasRequiredReader)return reader;hasRequiredReader=1,reader=o;var t,e=requireMinimal$1(),r=e.LongBits,i=e.utf8;function n(t,e){return RangeError("index out of range: "+t.pos+" + "+(e||1)+" > "+t.len)}function o(t){this.buf=t,this.pos=0,this.len=t.length}var u,s="undefined"!=typeof Uint8Array?function(t){if(t instanceof Uint8Array||Array.isArray(t))return new o(t);throw Error("illegal buffer")}:function(t){if(Array.isArray(t))return new o(t);throw Error("illegal buffer")},f=function(){return e.Buffer?function(r){return(o.create=function(r){return e.Buffer.isBuffer(r)?new t(r):s(r)})(r)}:s};function a(){var t=new r(0,0),e=0;if(!(this.len-this.pos>4)){for(;e<3;++e){if(this.pos>=this.len)throw n(this);if(t.lo=(t.lo|(127&this.buf[this.pos])<<7*e)>>>0,this.buf[this.pos++]<128)return t}return t.lo=(t.lo|(127&this.buf[this.pos++])<<7*e)>>>0,t}for(;e<4;++e)if(t.lo=(t.lo|(127&this.buf[this.pos])<<7*e)>>>0,this.buf[this.pos++]<128)return t;if(t.lo=(t.lo|(127&this.buf[this.pos])<<28)>>>0,t.hi=(t.hi|(127&this.buf[this.pos])>>4)>>>0,this.buf[this.pos++]<128)return t;if(e=0,this.len-this.pos>4){for(;e<5;++e)if(t.hi=(t.hi|(127&this.buf[this.pos])<<7*e+3)>>>0,this.buf[this.pos++]<128)return t}else for(;e<5;++e){if(this.pos>=this.len)throw n(this);if(t.hi=(t.hi|(127&this.buf[this.pos])<<7*e+3)>>>0,this.buf[this.pos++]<128)return t}throw Error("invalid varint encoding")}function h(t,e){return(t[e-4]|t[e-3]<<8|t[e-2]<<16|t[e-1]<<24)>>>0}function l(){if(this.pos+8>this.len)throw n(this,8);return new r(h(this.buf,this.pos+=4),h(this.buf,this.pos+=4))}return o.create=f(),o.prototype._slice=e.Array.prototype.subarray||e.Array.prototype.slice,o.prototype.uint32=(u=4294967295,function(){if(u=(127&this.buf[this.pos])>>>0,this.buf[this.pos++]<128)return u;if(u=(u|(127&this.buf[this.pos])<<7)>>>0,this.buf[this.pos++]<128)return u;if(u=(u|(127&this.buf[this.pos])<<14)>>>0,this.buf[this.pos++]<128)return u;if(u=(u|(127&this.buf[this.pos])<<21)>>>0,this.buf[this.pos++]<128)return u;if(u=(u|(15&this.buf[this.pos])<<28)>>>0,this.buf[this.pos++]<128)return u;if((this.pos+=5)>this.len)throw this.pos=this.len,n(this,10);return u}),o.prototype.int32=function(){return 0|this.uint32()},o.prototype.sint32=function(){var t=this.uint32();return t>>>1^-(1&t)},o.prototype.bool=function(){return 0!==this.uint32()},o.prototype.fixed32=function(){if(this.pos+4>this.len)throw n(this,4);return h(this.buf,this.pos+=4)},o.prototype.sfixed32=function(){if(this.pos+4>this.len)throw n(this,4);return 0|h(this.buf,this.pos+=4)},o.prototype.float=function(){if(this.pos+4>this.len)throw n(this,4);var t=e.float.readFloatLE(this.buf,this.pos);return this.pos+=4,t},o.prototype.double=function(){if(this.pos+8>this.len)throw n(this,4);var t=e.float.readDoubleLE(this.buf,this.pos);return this.pos+=8,t},o.prototype.bytes=function(){var t=this.uint32(),r=this.pos,i=this.pos+t;if(i>this.len)throw n(this,t);if(this.pos+=t,Array.isArray(this.buf))return this.buf.slice(r,i);if(r===i){var o=e.Buffer;return o?o.alloc(0):new this.buf.constructor(0)}return this._slice.call(this.buf,r,i)},o.prototype.string=function(){var t=this.bytes();return i.read(t,0,t.length)},o.prototype.skip=function(t){if("number"==typeof t){if(this.pos+t>this.len)throw n(this,t);this.pos+=t}else do{if(this.pos>=this.len)throw n(this)}while(128&this.buf[this.pos++]);return this},o.prototype.skipType=function(t){switch(t){case 0:this.skip();break;case 1:this.skip(8);break;case 2:this.skip(this.uint32());break;case 3:for(;4!=(t=7&this.uint32());)this.skipType(t);break;case 5:this.skip(4);break;default:throw Error("invalid wire type "+t+" at offset "+this.pos)}return this},o._configure=function(r){t=r,o.create=f(),t._configure();var i=e.Long?"toLong":"toNumber";e.merge(o.prototype,{int64:function(){return a.call(this)[i](!1)},uint64:function(){return a.call(this)[i](!0)},sint64:function(){return a.call(this).zzDecode()[i](!1)},fixed64:function(){return l.call(this)[i](!0)},sfixed64:function(){return l.call(this)[i](!1)}})},reader}function requireReader_buffer(){if(hasRequiredReader_buffer)return reader_buffer;hasRequiredReader_buffer=1,reader_buffer=r;var t=requireReader();(r.prototype=Object.create(t.prototype)).constructor=r;var e=requireMinimal$1();function r(e){t.call(this,e)}return r._configure=function(){e.Buffer&&(r.prototype._slice=e.Buffer.prototype.slice)},r.prototype.string=function(){var t=this.uint32();return this.buf.utf8Slice?this.buf.utf8Slice(this.pos,this.pos=Math.min(this.pos+t,this.len)):this.buf.toString("utf-8",this.pos,this.pos=Math.min(this.pos+t,this.len))},r._configure(),reader_buffer}var rpc={},service,hasRequiredService,hasRequiredRpc,roots,hasRequiredRoots,hasRequiredIndexMinimal,minimal,hasRequiredMinimal;function requireService(){if(hasRequiredService)return service;hasRequiredService=1,service=e;var t=requireMinimal$1();function e(e,r,i){if("function"!=typeof e)throw TypeError("rpcImpl must be a function");t.EventEmitter.call(this),this.rpcImpl=e,this.requestDelimited=Boolean(r),this.responseDelimited=Boolean(i)}return(e.prototype=Object.create(t.EventEmitter.prototype)).constructor=e,e.prototype.rpcCall=function e(r,i,n,o,u){if(!o)throw TypeError("request must be specified");var s=this;if(!u)return t.asPromise(e,s,r,i,n,o);if(s.rpcImpl)try{return s.rpcImpl(r,i[s.requestDelimited?"encodeDelimited":"encode"](o).finish(),function(t,e){if(t)return s.emit("error",t,r),u(t);if(null!==e){if(!(e instanceof n))try{e=n[s.responseDelimited?"decodeDelimited":"decode"](e)}catch(t){return s.emit("error",t,r),u(t)}return s.emit("data",e,r),u(null,e)}s.end(!0)})}catch(t){return s.emit("error",t,r),void setTimeout(function(){u(t)},0)}else setTimeout(function(){u(Error("already ended"))},0)},e.prototype.end=function(t){return this.rpcImpl&&(t||this.rpcImpl(null,null,null),this.rpcImpl=null,this.emit("end").off()),this},service}function requireRpc(){return hasRequiredRpc||(hasRequiredRpc=1,rpc.Service=requireService()),rpc}function requireRoots(){return hasRequiredRoots?roots:(hasRequiredRoots=1,roots={})}function requireIndexMinimal(){return hasRequiredIndexMinimal||(hasRequiredIndexMinimal=1,function(){var t=indexMinimal;function e(){t.util._configure(),t.Writer._configure(t.BufferWriter),t.Reader._configure(t.BufferReader)}t.build="minimal",t.Writer=requireWriter(),t.BufferWriter=requireWriter_buffer(),t.Reader=requireReader(),t.BufferReader=requireReader_buffer(),t.util=requireMinimal$1(),t.rpc=requireRpc(),t.roots=requireRoots(),t.configure=e,e()}()),indexMinimal}function requireMinimal(){return hasRequiredMinimal?minimal:(hasRequiredMinimal=1,minimal=requireIndexMinimal())}var minimalExports=requireMinimal(),Reader=minimalExports.Reader,Writer=minimalExports.Writer,roots$1=minimalExports.roots,util=minimalExports.util;export{Reader,Writer,roots$1 as roots,util}; 2 | -------------------------------------------------------------------------------- /src/pages/panel/panel.js: -------------------------------------------------------------------------------- 1 | import { 2 | AsyncLocalStorage, 3 | AsyncSessionStorage, 4 | base64toUint8Array, 5 | getForegroundTab, 6 | DeviceManager, 7 | RemoteCDMManager, 8 | PRDeviceManager, 9 | CustomHandlerManager, 10 | SettingsManager, 11 | escapeHTML, 12 | notifyUser 13 | } from "../../util.js"; 14 | 15 | import { CustomHandlers } from "../../lib/customhandlers/main.js"; 16 | 17 | const overlay = document.getElementById('overlay'); 18 | const overlayMessage = document.getElementById('overlayMessage'); 19 | const icon = document.getElementById('icon'); 20 | const main = document.getElementById('main'); 21 | const commandOptions = document.getElementById('command-options'); 22 | const advanced = document.getElementById('advanced'); 23 | const keysLabel = document.getElementById('keysLabel'); 24 | const keyContainer = document.getElementById('key-container'); 25 | 26 | let currentTab = null; 27 | 28 | // #region Main 29 | const enabled = document.getElementById('enabled'); 30 | 31 | const toggle = document.getElementById('scopeToggle'); 32 | const globalScopeLabel = document.getElementById('globalScopeLabel'); 33 | const siteScopeLabel = document.getElementById('siteScopeLabel'); 34 | const scopeInput = document.getElementById('scopeInput'); 35 | 36 | toggle.addEventListener('change', async () => { 37 | if (!toggle.checked) { 38 | const hostOverride = siteScopeLabel.dataset.hostOverride; 39 | if (hostOverride) { 40 | SettingsManager.removeProfile(hostOverride); 41 | window.close(); 42 | return; 43 | } 44 | SettingsManager.removeProfile(new URL(currentTab.url).host); 45 | loadConfig("global"); 46 | reloadButton.classList.remove("hidden"); 47 | } 48 | }); 49 | 50 | siteScopeLabel.addEventListener('click', function () { 51 | scopeInput.value = siteScopeLabel.dataset.hostOverride || new URL(currentTab.url).host; 52 | scopeInput.style.display = 'block'; 53 | scopeInput.focus(); 54 | }); 55 | scopeInput.addEventListener('keypress', function (event) { 56 | if (event.key === "Enter") { 57 | const hostOverride = scopeInput.value || new URL(currentTab.url).host; 58 | if (!hostOverride) { 59 | scopeInput.style.display = 'none'; 60 | return; 61 | } 62 | toggle.checked = true; 63 | toggle.disabled = false; 64 | globalScopeLabel.textContent = "Remove"; 65 | siteScopeLabel.innerHTML = escapeHTML(hostOverride) + "‎"; 66 | siteScopeLabel.dataset.hostOverride = hostOverride; 67 | scopeInput.style.display = 'none'; 68 | loadConfig(hostOverride); 69 | alert("Reopen the panel to remove the override"); 70 | } 71 | }); 72 | scopeInput.addEventListener('keydown', function (event) { 73 | if (event.key === "Escape") { 74 | scopeInput.style.display = 'none'; 75 | event.preventDefault(); 76 | } 77 | }); 78 | scopeInput.addEventListener('blur', function () { 79 | scopeInput.style.display = 'none'; 80 | }); 81 | 82 | const reloadButton = document.getElementById('reload'); 83 | reloadButton.addEventListener('click', async function () { 84 | chrome.tabs.reload(currentTab.id); 85 | window.close(); 86 | }); 87 | 88 | const version = document.getElementById('version'); 89 | version.textContent = "v" + chrome.runtime.getManifest().version_name; 90 | 91 | const wvEnabled = document.getElementById('wvEnabled'); 92 | const prEnabled = document.getElementById('prEnabled'); 93 | const ckEnabled = document.getElementById('ckEnabled'); 94 | const blockDisabled = document.getElementById('blockDisabled'); 95 | 96 | const wvdSelect = document.getElementById('wvdSelect'); 97 | const remoteSelect = document.getElementById('remoteSelect'); 98 | const customSelect = document.getElementById('customSelect'); 99 | const prdSelect = document.getElementById('prdSelect'); 100 | const prRemoteSelect = document.getElementById('prRemoteSelect'); 101 | const prCustomSelect = document.getElementById('prCustomSelect'); 102 | 103 | const wvdCombobox = document.getElementById('wvd-combobox'); 104 | const remoteCombobox = document.getElementById('remote-combobox'); 105 | const prdCombobox = document.getElementById('prd-combobox'); 106 | const prRemoteCombobox = document.getElementById('pr-remote-combobox'); 107 | 108 | const wvServerCert = document.getElementById('wv-server-cert'); 109 | const maxHdcp = document.getElementById('max-hdcp'); 110 | const maxHdcpLabel = document.getElementById('max-hdcp-label'); 111 | const maxRobustness = document.getElementById('max-robustness'); 112 | const allowSL3K = document.getElementById('allowSL3K'); 113 | const allowPersistence = document.getElementById('allowPersistence'); 114 | 115 | [ 116 | enabled, 117 | wvEnabled, prEnabled, ckEnabled, blockDisabled, 118 | wvdSelect, remoteSelect, customSelect, 119 | prdSelect, prRemoteSelect, prCustomSelect, 120 | wvdCombobox, remoteCombobox, 121 | prdCombobox, prRemoteCombobox, 122 | wvServerCert, maxRobustness, allowSL3K, allowPersistence 123 | ].forEach(elem => { 124 | elem.addEventListener('change', async function () { 125 | applyConfig(); 126 | }); 127 | }); 128 | 129 | [main, commandOptions, advanced].forEach(elem => { 130 | elem.addEventListener('toggle', async function () { 131 | SettingsManager.setUICollapsed(!main.open, !commandOptions.open, !advanced.open); 132 | }); 133 | }); 134 | 135 | maxHdcp.addEventListener('input', function () { 136 | maxHdcpLabel.textContent = getHdcpLevelLabel(maxHdcp.value); 137 | applyConfig(); 138 | }); 139 | 140 | const exportButton = document.getElementById('export'); 141 | exportButton.addEventListener('click', async function () { 142 | const storage = currentTab.incognito ? AsyncSessionStorage : AsyncLocalStorage; 143 | const logs = Object.values(await storage.getStorage(null)); 144 | const encoded = new TextEncoder().encode(JSON.stringify(logs) + "\n"); 145 | SettingsManager.downloadFile(encoded, currentTab.incognito ? "logs-incognito.json" : "logs.json"); 146 | }); 147 | 148 | for (const a of document.getElementsByTagName('a')) { 149 | a.addEventListener('click', (event) => { 150 | event.preventDefault(); 151 | chrome.tabs.create({ url: a.href }); 152 | window.close(); 153 | }); 154 | } 155 | // #endregion Main 156 | 157 | // #region Widevine Local 158 | document.getElementById('fileInput').addEventListener('click', () => { 159 | chrome.runtime.sendMessage({ type: "OPEN_PICKER_WVD" }); 160 | window.close(); 161 | }); 162 | 163 | const remove = document.getElementById('remove'); 164 | remove.addEventListener('click', async function () { 165 | await DeviceManager.removeWidevineDevice(wvdCombobox.options[wvdCombobox.selectedIndex]?.text || ""); 166 | wvdCombobox.innerHTML = ''; 167 | await DeviceManager.loadSetAllWidevineDevices(); 168 | applyConfig(); 169 | if (wvdCombobox.options.length === 0) { 170 | remove.disabled = true; 171 | download.disabled = true; 172 | } 173 | }); 174 | 175 | const download = document.getElementById('download'); 176 | download.addEventListener('click', async function () { 177 | const widevineDevice = wvdCombobox.options[wvdCombobox.selectedIndex]?.text; 178 | if (!widevineDevice) { 179 | return; 180 | } 181 | SettingsManager.downloadFile( 182 | base64toUint8Array(await DeviceManager.loadWidevineDevice(widevineDevice)), 183 | widevineDevice + ".wvd" 184 | ); 185 | }); 186 | // #endregion Widevine Local 187 | 188 | // #region Playready Local 189 | document.getElementById('prdInput').addEventListener('click', () => { 190 | chrome.runtime.sendMessage({ type: "OPEN_PICKER_PRD" }); 191 | window.close(); 192 | }); 193 | 194 | const prdRemove = document.getElementById('prdRemove'); 195 | prdRemove.addEventListener('click', async function() { 196 | await PRDeviceManager.removePlayreadyDevice(prdCombobox.options[prdCombobox.selectedIndex]?.text || ""); 197 | prdCombobox.innerHTML = ''; 198 | await PRDeviceManager.loadSetAllPlayreadyDevices(); 199 | applyConfig(); 200 | if (prdCombobox.options.length === 0) { 201 | prdRemove.disabled = true; 202 | prdDownload.disabled = true; 203 | } 204 | }); 205 | 206 | const prdDownload = document.getElementById('prdDownload'); 207 | prdDownload.addEventListener('click', async function() { 208 | const playreadyDevice = prdCombobox.options[prdCombobox.selectedIndex]?.text; 209 | if (!playreadyDevice) { 210 | return; 211 | } 212 | SettingsManager.downloadFile( 213 | base64toUint8Array(await PRDeviceManager.loadPlayreadyDevice(playreadyDevice)), 214 | playreadyDevice + ".prd" 215 | ); 216 | }); 217 | // #endregion Playready Local 218 | 219 | // #region Remote CDM 220 | [ 221 | document.getElementById('remoteInput'), 222 | document.getElementById('prRemoteInput') 223 | ].forEach(elem => { 224 | elem.addEventListener('click', () => { 225 | chrome.runtime.sendMessage({ type: "OPEN_PICKER_REMOTE" }); 226 | window.close(); 227 | }); 228 | }); 229 | 230 | const remoteRemove = document.getElementById('remoteRemove'); 231 | remoteRemove.addEventListener('click', async function() { 232 | await RemoteCDMManager.removeRemoteCDM(remoteCombobox.options[remoteCombobox.selectedIndex]?.text || ""); 233 | remoteCombobox.innerHTML = ''; 234 | await RemoteCDMManager.loadSetWVRemoteCDMs(); 235 | applyConfig(); 236 | if (remoteCombobox.options.length === 0) { 237 | remoteRemove.disabled = true; 238 | remoteDownload.disabled = true; 239 | } 240 | }); 241 | const prRemoteRemove = document.getElementById('prRemoteRemove'); 242 | prRemoteRemove.addEventListener('click', async function() { 243 | await RemoteCDMManager.removeRemoteCDM(prRemoteCombobox.options[prRemoteCombobox.selectedIndex]?.text || ""); 244 | prRemoteCombobox.innerHTML = ''; 245 | await RemoteCDMManager.loadSetPRRemoteCDMs(); 246 | applyConfig(); 247 | if (prRemoteCombobox.options.length === 0) { 248 | prRemoteRemove.disabled = true; 249 | prRemoteDownload.disabled = true; 250 | } 251 | }); 252 | 253 | async function downloadRemote(remoteCdmName) { 254 | let remoteCdm = await RemoteCDMManager.loadRemoteCDM(remoteCdmName); 255 | if (!remoteCdm.endsWith('\n')) { 256 | remoteCdm += '\n'; 257 | } 258 | SettingsManager.downloadFile(new TextEncoder().encode(remoteCdm), remoteCdmName + ".json"); 259 | } 260 | const remoteDownload = document.getElementById('remoteDownload'); 261 | remoteDownload.addEventListener('click', async function() { 262 | const remoteCdm = remoteCombobox.options[remoteCombobox.selectedIndex]?.text; 263 | if (!remoteCdm) { 264 | return; 265 | } 266 | downloadRemote(remoteCdm); 267 | }); 268 | const prRemoteDownload = document.getElementById('prRemoteDownload'); 269 | prRemoteDownload.addEventListener('click', async function() { 270 | const remoteCdm = prRemoteCombobox.options[prRemoteCombobox.selectedIndex]?.text; 271 | if (!remoteCdm) { 272 | return; 273 | } 274 | downloadRemote(remoteCdm); 275 | }); 276 | // #endregion Remote CDM 277 | 278 | // #region Custom Handlers 279 | const customCombobox = document.getElementById('custom-combobox'); 280 | const customDesc = document.getElementById('custom-desc'); 281 | const prCustomCombobox = document.getElementById('pr-custom-combobox'); 282 | const prCustomDesc = document.getElementById('pr-custom-desc'); 283 | customCombobox.addEventListener('change', function () { 284 | customDesc.textContent = CustomHandlers[customCombobox.value].description; 285 | applyConfig(); 286 | }); 287 | prCustomCombobox.addEventListener('change', function () { 288 | prCustomDesc.textContent = CustomHandlers[prCustomCombobox.value].description; 289 | applyConfig(); 290 | }); 291 | // #endregion Custom Handlers 292 | 293 | // #region Command Options 294 | const decryptionEngineSelect = document.getElementById('decryption-engine-select'); 295 | const muxerSelect = document.getElementById('muxer-select'); 296 | const formatSelect = document.getElementById('format-select'); 297 | const videoStreamSelect = document.getElementById('video-stream-select'); 298 | const audioAllCheckbox = document.getElementById('audio-all'); 299 | const subsAllCheckbox = document.getElementById('subs-all'); 300 | 301 | const downloaderName = document.getElementById('downloader-name'); 302 | downloaderName.addEventListener('input', function () { 303 | SettingsManager.saveExecutableName(downloaderName.value); 304 | reloadAllCommands(); 305 | }); 306 | 307 | async function saveCommandOptions() { 308 | const opts = { 309 | decryptionEngine: decryptionEngineSelect.value, 310 | muxer: muxerSelect.value, 311 | format: formatSelect.value, 312 | videoStream: videoStreamSelect.value, 313 | audioAll: audioAllCheckbox.checked, 314 | subsAll: subsAllCheckbox.checked 315 | }; 316 | await SettingsManager.saveCommandOptions(opts); 317 | await reloadAllCommands(); 318 | } 319 | 320 | async function restoreCommandOptions() { 321 | const opts = await SettingsManager.getCommandOptions?.() || {}; 322 | decryptionEngineSelect.value = opts.decryptionEngine || 'SHAKA_PACKAGER'; 323 | muxerSelect.value = opts.muxer || 'ffmpeg'; 324 | formatSelect.value = opts.format || 'mp4'; 325 | videoStreamSelect.value = opts.videoStream || 'best'; 326 | audioAllCheckbox.checked = !!opts.audioAll; 327 | subsAllCheckbox.checked = !!opts.subsAll; 328 | } 329 | 330 | [ 331 | decryptionEngineSelect, 332 | muxerSelect, 333 | formatSelect, 334 | videoStreamSelect, 335 | audioAllCheckbox, 336 | subsAllCheckbox 337 | ].forEach(elem => { 338 | elem.addEventListener('change', saveCommandOptions); 339 | }); 340 | // #endregion Command Options 341 | 342 | // #region Logs 343 | const clear = document.getElementById('clear'); 344 | clear.addEventListener('click', async function() { 345 | const storage = currentTab.incognito ? chrome.storage.session : chrome.storage.local; 346 | storage.clear(); 347 | keyContainer.innerHTML = ""; 348 | }); 349 | 350 | async function createCommand(json, keyString, title) { 351 | const metadata = JSON.parse(json); 352 | const headerString = Object.entries(metadata.headers).map(([key, value]) => `-H "${key}: ${value.replace(/"/g, "'")}"`).join(' '); 353 | 354 | // Get selected decryption engine 355 | let engineArg = `--decryption-engine ${decryptionEngineSelect.value}`; 356 | 357 | // Get selected muxer and format, combine as required 358 | const muxer = muxerSelect.value; 359 | const format = formatSelect.value; 360 | let formatMuxerArg = `-M format=${format}:muxer=${muxer}`; 361 | 362 | // Stream options 363 | let streamArgs = []; 364 | const videoStream = videoStreamSelect.value; 365 | if (videoStream === "best") streamArgs.push('-sv best'); 366 | if (videoStream === "1080") streamArgs.push('-sv res="1080*"'); 367 | if (audioAllCheckbox.checked) streamArgs.push('-sa all'); 368 | if (subsAllCheckbox.checked) streamArgs.push('-ss all'); 369 | 370 | return `${await SettingsManager.getExecutableName()} "${metadata.url}" ${headerString} ${keyString} ${engineArg} ${formatMuxerArg} ${streamArgs.join(' ')}${title ? ` --save-name "${title}"` : ""}`.trim(); 371 | } 372 | 373 | async function reloadAllCommands() { 374 | const logContainers = document.querySelectorAll('.log-container'); 375 | for (const logContainer of logContainers) { 376 | const command = logContainer.querySelector('.command-box'); 377 | if (!command) { 378 | continue; 379 | } 380 | const select = logContainer.querySelector(".manifest-box"); 381 | const key = logContainer.querySelector('.key-box'); 382 | command.value = await createCommand(select.value, key.value, logContainer.log.title); 383 | } 384 | } 385 | 386 | function getFriendlyType(type) { 387 | switch (type) { 388 | case "CLEARKEY": 389 | return "ClearKey"; 390 | case "WIDEVINE": 391 | return "Widevine"; 392 | case "PLAYREADY": 393 | return "PlayReady"; 394 | default: 395 | return type; 396 | } 397 | } 398 | 399 | async function appendLog(result, testDuplicate) { 400 | const keyString = result.keys.map(key => `--key ${key.kid}:${key.k}`).join(' '); 401 | const date = new Date(result.timestamp * 1000); 402 | const dateString = date.toLocaleString(); 403 | 404 | const logContainer = document.createElement('div'); 405 | logContainer.classList.add('log-container'); 406 | 407 | const pssh = result.pssh || result.pssh_data || result.wrm_header; 408 | 409 | logContainer.innerHTML = ` 410 | 411 | `; 441 | 442 | const keysInput = logContainer.querySelector('.key-copy'); 443 | keysInput.addEventListener('click', () => { 444 | navigator.clipboard.writeText(keyString); 445 | }); 446 | 447 | if (result.sessions?.length > 0) { 448 | const sessionSelect = logContainer.querySelector(".session-box"); 449 | const option = new Option(`${result.sessions.length} persistent sessions`, ""); 450 | sessionSelect.add(option); 451 | 452 | result.sessions.forEach((session) => { 453 | const option = new Option(session, session); 454 | sessionSelect.add(option); 455 | }); 456 | 457 | const sessionCopy = logContainer.querySelector('.session-copy'); 458 | sessionCopy.addEventListener('click', () => { 459 | if (sessionSelect.selectedIndex === 0) return; 460 | navigator.clipboard.writeText(sessionSelect.value); 461 | }); 462 | sessionCopy.addEventListener('contextmenu', (event) => { 463 | if (sessionSelect.selectedIndex === 0) return; 464 | event.preventDefault(); 465 | result.sessions.splice(sessionSelect.selectedIndex - 1, 1); 466 | sessionSelect.remove(sessionSelect.selectedIndex); 467 | const storage = currentTab.incognito ? AsyncSessionStorage : AsyncLocalStorage; 468 | storage.setStorage({ [pssh + result.origin]: result }); 469 | }); 470 | } 471 | 472 | if (result.manifests?.length > 0) { 473 | const command = logContainer.querySelector('.command-box'); 474 | 475 | const select = logContainer.querySelector(".manifest-box"); 476 | select.addEventListener('change', async () => { 477 | command.value = await createCommand(select.value, keyString, result.title); 478 | }); 479 | result.manifests.forEach((manifest) => { 480 | const option = new Option(`[${manifest.type}] ${manifest.url}`, JSON.stringify(manifest)); 481 | select.add(option); 482 | }); 483 | command.value = await createCommand(select.value, keyString, result.title); 484 | 485 | const manifestCopy = logContainer.querySelector('.manifest-copy'); 486 | manifestCopy.addEventListener('click', () => { 487 | navigator.clipboard.writeText(JSON.parse(select.value).url); 488 | }); 489 | 490 | const commandCopy = logContainer.querySelector('.command-copy'); 491 | commandCopy.addEventListener('click', () => { 492 | navigator.clipboard.writeText(command.value); 493 | }); 494 | } 495 | 496 | const toggleButton = logContainer.querySelector('.toggleButton'); 497 | toggleButton.addEventListener('click', function () { 498 | const expandableDiv = this.nextElementSibling; 499 | if (expandableDiv.classList.contains('collapsed')) { 500 | toggleButton.innerHTML = "-"; 501 | expandableDiv.classList.remove('collapsed'); 502 | expandableDiv.classList.add('expanded'); 503 | } else { 504 | toggleButton.innerHTML = "+"; 505 | expandableDiv.classList.remove('expanded'); 506 | expandableDiv.classList.add('collapsed'); 507 | } 508 | }); 509 | 510 | const removeButton = logContainer.querySelector('.removeButton'); 511 | removeButton.addEventListener('click', () => { 512 | logContainer.remove(); 513 | const storage = currentTab.incognito ? AsyncSessionStorage : AsyncLocalStorage; 514 | storage.removeStorage([pssh + (result.origin ?? '')]); 515 | }); 516 | 517 | for (const a of logContainer.getElementsByTagName('a')) { 518 | a.addEventListener('click', (event) => { 519 | event.preventDefault(); 520 | }); 521 | } 522 | 523 | // Remote duplicate existing entry 524 | if (testDuplicate) { 525 | const logContainers = keyContainer.querySelectorAll('.log-container'); 526 | logContainers.forEach(container => { 527 | if (container.log.pssh === pssh && container.log.origin === result.origin) { 528 | container.remove(); 529 | } 530 | }); 531 | } 532 | 533 | logContainer.log = result; 534 | 535 | keyContainer.appendChild(logContainer); 536 | 537 | updateIcon(); 538 | } 539 | 540 | function getHdcpLevelLabel(levelId) { 541 | switch (parseInt(levelId)) { 542 | case 0: return "None"; 543 | case 1: return "1.0"; 544 | case 2: return "1.1"; 545 | case 3: return "1.2"; 546 | case 4: return "1.3"; 547 | case 5: return "1.4"; 548 | case 6: return "2.0"; 549 | case 7: return "2.1"; 550 | case 8: return "2.2"; 551 | case 9: return "2.3"; 552 | } 553 | } 554 | 555 | chrome.storage.onChanged.addListener(async (changes, areaName) => { 556 | if (areaName === 'local') { 557 | for (const [key, values] of Object.entries(changes)) { 558 | await appendLog(values.newValue, true); 559 | } 560 | } 561 | if (areaName === 'session') { 562 | for (const [key, values] of Object.entries(changes)) { 563 | await appendLog(values.newValue, true, true); 564 | } 565 | } 566 | }); 567 | 568 | async function checkLogs() { 569 | const storage = currentTab.incognito ? AsyncSessionStorage : AsyncLocalStorage; 570 | const logs = await storage.getStorage(null); 571 | Object.values(logs).forEach(async (result) => { 572 | await appendLog(result, false); 573 | }); 574 | } 575 | // #endregion Keys 576 | 577 | // #region Initialization and Config Management 578 | async function loadConfig(scope = "global") { 579 | const profileConfig = await SettingsManager.getProfile(scope); 580 | enabled.checked = await SettingsManager.getGlobalEnabled() && profileConfig.enabled; 581 | wvEnabled.checked = profileConfig.widevine.enabled; 582 | prEnabled.checked = profileConfig.playready.enabled; 583 | ckEnabled.checked = profileConfig.clearkey.enabled; 584 | blockDisabled.checked = profileConfig.blockDisabled; 585 | wvServerCert.value = profileConfig.widevine.serverCert || "if_provided"; 586 | maxHdcp.value = profileConfig.hdcp ?? 9; 587 | maxHdcpLabel.textContent = getHdcpLevelLabel(maxHdcp.value); 588 | maxRobustness.value = profileConfig.widevine.robustness || "HW_SECURE_ALL"; 589 | allowSL3K.checked = profileConfig.playready.allowSL3K !== false; 590 | allowPersistence.checked = profileConfig.allowPersistence; 591 | SettingsManager.setSelectedDeviceType(profileConfig.widevine.type); 592 | await DeviceManager.selectWidevineDevice(profileConfig.widevine.device.local); 593 | await RemoteCDMManager.selectRemoteCDM(profileConfig.widevine.device.remote); 594 | CustomHandlerManager.selectCustomHandler(profileConfig.widevine.device.custom); 595 | SettingsManager.setSelectedPRDeviceType(profileConfig.playready.type); 596 | await PRDeviceManager.selectPlayreadyDevice(profileConfig.playready.device.local); 597 | await RemoteCDMManager.selectPRRemoteCDM(profileConfig.playready.device.remote); 598 | CustomHandlerManager.selectPRCustomHandler(profileConfig.playready.device.custom); 599 | updateIcon(); 600 | main.dataset.wvType = profileConfig.widevine.type; 601 | main.dataset.prType = profileConfig.playready.type; 602 | } 603 | 604 | async function applyConfig() { 605 | const scope = siteScopeLabel.dataset.hostOverride || (toggle.checked ? new URL(currentTab.url).host : "global"); 606 | const wvType = wvdSelect.checked ? "local" : (remoteSelect.checked ? "remote" : "custom"); 607 | const prType = prdSelect.checked ? "local" : (prRemoteSelect.checked ? "remote" : "custom"); 608 | const config = { 609 | "enabled": enabled.checked, 610 | "widevine": { 611 | "enabled": wvEnabled.checked, 612 | "device": { 613 | "local": wvdCombobox.options[wvdCombobox.selectedIndex]?.text || null, 614 | "remote": remoteCombobox.options[remoteCombobox.selectedIndex]?.text || null, 615 | "custom": customCombobox.value 616 | }, 617 | "type": wvType, 618 | "serverCert": wvServerCert.value, 619 | "robustness": maxRobustness.value 620 | }, 621 | "playready": { 622 | "enabled": prEnabled.checked, 623 | "device": { 624 | "local": prdCombobox.options[prdCombobox.selectedIndex]?.text || null, 625 | "remote": prRemoteCombobox.options[prRemoteCombobox.selectedIndex]?.text || null, 626 | "custom": prCustomCombobox.value 627 | }, 628 | "type": prType, 629 | "allowSL3K": allowSL3K.checked 630 | }, 631 | "clearkey": { 632 | "enabled": ckEnabled.checked 633 | }, 634 | "hdcp": parseInt(maxHdcp.value), 635 | "blockDisabled": blockDisabled.checked, 636 | "allowPersistence": allowPersistence.checked 637 | }; 638 | main.dataset.wvType = wvType; 639 | main.dataset.prType = prType; 640 | await SettingsManager.setProfile(scope, config); 641 | // If Vineless is globally disabled, per-site enabled config is completely ignored 642 | // Enable both global and per-site when switching the per-site one to enabled, if global was disabled 643 | if (scope === "global" || (config.enabled && !await SettingsManager.getGlobalEnabled())) { 644 | await SettingsManager.setGlobalEnabled(config.enabled); 645 | } 646 | // Show the reload button if not in override mode 647 | // (makes no sense in override mode as it's not the current site) 648 | if (!siteScopeLabel.dataset.hostOverride) { 649 | reloadButton.classList.remove('hidden'); 650 | } 651 | updateIcon(); 652 | } 653 | 654 | async function getSessionCount() { 655 | return new Promise((resolve, reject) => { 656 | chrome.runtime.sendMessage({ type: "GET_ACTIVE", body: currentTab.id }, (response) => { 657 | resolve(response); 658 | }); 659 | }); 660 | } 661 | 662 | async function updateIcon() { 663 | const sessionCnt = await getSessionCount(); 664 | if (sessionCnt > 0) { 665 | icon.src = "../../images/icon-active.png"; 666 | } else if (sessionCnt === 0) { 667 | icon.src = "../../images/icon-closed.png"; 668 | } else if (await SettingsManager.getGlobalEnabled()) { 669 | icon.src = "../../images/icon.png"; 670 | } else { 671 | icon.src = "../../images/icon-disabled.png"; 672 | } 673 | } 674 | 675 | function timeoutPromise(promise, ms) { 676 | let timer; 677 | const timeout = new Promise((_, reject) => { 678 | timer = setTimeout(() => reject(new Error("timeout")), ms); 679 | }); 680 | return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); 681 | } 682 | 683 | document.addEventListener('DOMContentLoaded', async function () { 684 | const configs = [ 685 | { 686 | "initDataTypes": ["cenc"], 687 | "videoCapabilities": [ 688 | {"contentType": "video/mp4;codecs=\"avc1.64001f\"", "robustness": ""}, 689 | {"contentType": "video/mp4;codecs=\"avc1.4D401F\"", "robustness": ""}, 690 | {"contentType": "video/mp4;codecs=\"avc1.42E01E\"", "robustness": ""} 691 | ], 692 | "distinctiveIdentifier": "optional", 693 | "persistentState": "optional", 694 | "sessionTypes": ["temporary"] 695 | } 696 | ]; 697 | 698 | try { 699 | // Probe ClearKey support 700 | // Tor Browser might return a never-resolving promise on RMKSA so use a timeout 701 | await timeoutPromise(navigator.requestMediaKeySystemAccess('org.w3.clearkey', configs), 3000); 702 | overlay.style.display = 'none'; 703 | 704 | const { devicesCollapsed, commandsCollapsed, advancedCollapsed } = await SettingsManager.getUICollapsed(); 705 | if (!devicesCollapsed) { 706 | main.open = true; 707 | } 708 | if (!commandsCollapsed) { 709 | commandOptions.open = true; 710 | } 711 | if (!advancedCollapsed) { 712 | advanced.open = true; 713 | } 714 | currentTab = await getForegroundTab(); 715 | const host = new URL(currentTab.url).host; 716 | if (host) { 717 | siteScopeLabel.innerHTML = escapeHTML(host) + "‎"; 718 | if (await SettingsManager.profileExists(host)) { 719 | toggle.checked = true; 720 | } 721 | } else { 722 | siteScopeLabel.textContent = ""; 723 | toggle.disabled = true; 724 | } 725 | if (currentTab.incognito) { 726 | keysLabel.textContent = "Keys (Incognito)"; 727 | } 728 | downloaderName.value = await SettingsManager.getExecutableName(); 729 | await restoreCommandOptions(); 730 | CustomHandlerManager.loadSetAllCustomHandlers(); 731 | await DeviceManager.loadSetAllWidevineDevices(); 732 | await RemoteCDMManager.loadSetAllRemoteCDMs(); 733 | await PRDeviceManager.loadSetAllPlayreadyDevices(); 734 | loadConfig(host); 735 | checkLogs(); 736 | } catch (e) { 737 | // bail out 738 | console.error(e); 739 | if ((e.name === "NotSupportedError" || e.name === "TypeError") && overlay.style.display !== 'none') { 740 | overlayMessage.innerHTML = "This browser does not support either EME or ClearKey!
Vineless cannot work without those!"; 741 | document.body.style.overflow = "hidden"; 742 | } else { 743 | notifyUser("Vineless", "An unknown error occurred while loading the panel!\n" + e.message); 744 | } 745 | } 746 | }); 747 | // #endregion Initialization and Config Management 748 | --------------------------------------------------------------------------------