├── .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 |
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
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 |
20 |
39 |
40 | Systems & Devices
41 |
74 |
81 |
88 |
93 |
100 |
107 |
112 |
113 |
114 | Command Options
115 |
155 |
156 |
157 | Advanced Options
158 |
193 |
194 |
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 |
--------------------------------------------------------------------------------