import { base64toUint8Array, uint8ArrayToBase64, uint8ArrayToHex, hexToUint8Array, compareUint8Arrays, flipUUIDByteOrder, RemoteCDMManager } from "../util.js"; export class GenericRemoteDevice { constructor(host, keySystem, sessionId, tab) { this.host = host; this.isPlayReady = keySystem.includes("playready"); } async generateChallenge(pssh, extra) { const { serverCert } = extra; if (!pssh) { throw new Error("No PSSH data in challenge"); } const selectedRemoteCdmName = await RemoteCDMManager[this.isPlayReady ? 'getSelectedPRRemoteCDM' : 'getSelectedRemoteCDM'](this.host); if (!selectedRemoteCdmName) { throw new Error("No Remote CDM selected"); } const selectedRemoteCdm = JSON.parse(await RemoteCDMManager.loadRemoteCDM(selectedRemoteCdmName)); selectedRemoteCdm.sg_api_conf = Object.assign(getDefaultSGConfig(selectedRemoteCdm.type), selectedRemoteCdm.sg_api_conf || {}); this.remoteCdm = new RemoteCdm(selectedRemoteCdm); this.pssh = pssh; if (this.isPlayReady) { const challengeData = base64toUint8Array(pssh); const challenge = new TextDecoder("utf-16le").decode(challengeData); this.wrmHeader = challenge.match(//gm)[0]; if (!this.remoteCdm.apiConf.prPsshAsIs) { this.pssh = this.wrmHeader; } } else { // Use hex-encoded raw key ID for WEBM when saving logs const psshBytes = base64toUint8Array(pssh); const PSSH_MAGIC = new Uint8Array([0x70, 0x73, 0x73, 0x68]); if (!compareUint8Arrays(psshBytes.subarray(4, 8), PSSH_MAGIC)) { this.hexPssh = uint8ArrayToHex(psshBytes); } } try { const challengeB64 = await this.remoteCdm.generateChallenge(this.pssh, serverCert); return challengeB64; } catch (error) { let message = error.message; if (this.remoteCdm.lastMsg) { message = "Server returned message: " + this.remoteCdm.lastMsg; } else if (message.includes("fetch")) { message += "\nMake sure the server is reachable."; } throw new Error("Remote: " + message); } } async parseLicense(license) { try { const keysData = await this.remoteCdm.parseLicense(license); if (!keysData) { throw new Error("No keys were received from the remote CDM!"); } const keys = this.remoteCdm.parseKeys(keysData); if (keys.length === 0) { throw new Error("No keys were received from the remote CDM!"); } return { type: this.remoteCdm.type, // Always prefer WRMHEADER here if available, to prevent duplicate log entries when switching PR local and remote pssh: this.wrmHeader || this.hexPssh || this.pssh, keys: keys }; } catch (error) { let message = error.message; if (this.remoteCdm.lastMsg) { message = "Server returned message: " + this.remoteCdm.lastMsg; } else if (message.includes("fetch")) { message += "\nMake sure the server is reachable."; } throw new Error("Remote: " + message); } } } export class RemoteCdm { constructor(dataObj) { this.type = dataObj.type || "WIDEVINE"; this.device_type = dataObj.device_type; this.system_id = dataObj.system_id; this.security_level = dataObj.security_level; this.device_name = dataObj.device_name || dataObj.name; this.name_override = dataObj.name_override; this.apiConf = dataObj.sg_api_conf; this.baseUrl = dataObj.host || ""; this.baseHeaders = this.apiConf?.headers || {}; this.overridingHeaders = this.apiConf?.overrideHeaders; if (this.overridingHeaders) { registerOverrideHeaders(this.overridingHeaders.headers, this.overridingHeaders.urls); } this.secret = dataObj.secret; for (const [key, value] of Object.entries(this.baseHeaders)) { if (value === "{secret}") { if (this.secret) { this.baseHeaders[key] = this.secret; } else { delete this.baseHeaders[key]; } } } } getName() { let name = this.name_override; if (!name) { name = this.baseUrl + "/" + this.device_name; } if (this.type === "PLAYREADY") { let type = "PR"; switch (this.security_level + '') { case "3000": type = "SL3K" break; case "2000": type = "SL2K" break; default: type = "SL" + this.security_level; } return `[${type}] ${name}`; } const type = this.device_type === "CHROME" ? "CHROME" : `L${this.security_level}`; return `[${type}] ${name} (${this.system_id})`; } async generateChallenge(pssh, serverCert) { this.pssh = pssh; this.serverCert = serverCert; const apiData = this.apiConf.generateChallenge; const requests = Array.isArray(apiData) ? apiData : [apiData]; for (const request of requests) { if (request.serverCertOnly && !serverCert) { continue; } const options = { method: request.method || "POST", headers: Object.assign(this.baseHeaders, request.headers), }; if (options.method === "POST") { let data = {}; if (request.bodyObj) { data = request.bodyObj; } if (request.sessionIdKeyName) { setNestedProperty(data, request.sessionIdKeyName, this.sessionId); } if (request.psshKeyName) { setNestedProperty(data, request.psshKeyName, this.pssh); } if (request.serverCertKeyName) { setNestedProperty(data, request.serverCertKeyName, this.serverCert); } options.body = JSON.stringify(data); } const res = await fetch(this.baseUrl + request.url.replaceAll("{device_name}", this.device_name).replace("%s", this.sessionId), options); const jsonData = await res.json(); const messageKey = request.messageKey || this.apiConf.messageKey; if (messageKey) { this.lastMsg = getNestedProperty(jsonData, messageKey); } if (request.sessionIdResKeyName) { this.sessionId = getNestedProperty(jsonData, request.sessionIdResKeyName); if (!this.sessionId) { throw new Error("Server did not return a session ID"); } } if (request.challengeKeyName) { this.challenge = getNestedProperty(jsonData, request.challengeKeyName); if (!this.challenge) { throw new Error("Server did not return a challenge"); } if (request.encodeB64) { this.challenge = btoa(this.challenge); } if (request.bundleInKeyMessage) { const newXmlDoc = ` ${this.challenge} Content-Type text/xml; charset=utf-8 SOAPAction "http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense" `.replace(/ |\n/g, ''); const utf8KeyMessage = new TextEncoder().encode(newXmlDoc); const newKeyMessage = new Uint8Array(utf8KeyMessage.length * 2); for (let i = 0; i < utf8KeyMessage.length; i++) { newKeyMessage[i * 2] = utf8KeyMessage[i]; newKeyMessage[i * 2 + 1] = 0; } this.challenge = uint8ArrayToBase64(newKeyMessage); } } } return this.challenge; } async parseLicense(licenseB64) { const apiData = this.apiConf.parseLicense; const requests = Array.isArray(apiData) ? apiData : [apiData]; for (const request of requests) { const options = { method: request.method || "POST", headers: Object.assign(this.baseHeaders, request.headers), }; if (options.method === "POST") { let data = {}; if (request.bodyObj) { data = request.bodyObj; } if (request.sessionIdKeyName) { setNestedProperty(data, request.sessionIdKeyName, this.sessionId); } if (request.psshKeyName) { setNestedProperty(data, request.psshKeyName, this.pssh); } if (request.serverCertKeyName) { setNestedProperty(data, request.serverCertKeyName, this.serverCert); } if (request.challengeKeyName) { setNestedProperty(data, request.challengeKeyName, this.challenge); } if (request.licenseKeyName) { setNestedProperty(data, request.licenseKeyName, request.decodeB64 ? atob(licenseB64) : licenseB64); } options.body = JSON.stringify(data); } const res = await fetch(this.baseUrl + request.url.replaceAll("{device_name}", this.device_name).replace("%s", this.sessionId), options); const jsonData = await res.json(); const messageKey = request.messageKey || this.apiConf.messageKey; if (messageKey) { this.lastMsg = getNestedProperty(jsonData, messageKey); } if (request.sessionIdResKeyName) { this.sessionId = getNestedProperty(jsonData, request.sessionIdResKeyName); if (!this.sessionId) { throw new Error("Server did not return a session ID"); } } if (request.contentKeysKeyName) { this.contentKeys = getNestedProperty(jsonData, request.contentKeysKeyName); } } return this.contentKeys; } parseKeys(keysData) { const apiData = this.apiConf.keyParseRules; const keys = []; if (apiData.regex) { const regex = new RegExp(apiData.regex.data, 'g'); let match = null; do { let k, kid; match = regex.exec(keysData); if (match) { if (apiData.regex.keyFirst) { k = match[1]; kid = match[2]; } else { k = match[2]; kid = match[1]; } keys.push({ k, kid }); } } while (match); } else { const mainArray = getNestedProperty(keysData, apiData.mainArrayKeyName || []); for (const item of mainArray) { const k = getNestedProperty(item, apiData.keyKeyName); const kid = getNestedProperty(item, apiData.kidKeyName); keys.push({ k, kid }); } } if (apiData.base64) { for (const key of keys) { key.k = uint8ArrayToHex(base64toUint8Array(key.k)), key.kid = uint8ArrayToHex(base64toUint8Array(key.kid)) } } if (apiData.needsFlipping) { for (const key of keys) { key.k = uint8ArrayToHex(flipUUIDByteOrder(hexToUint8Array(key.k))); key.kid = uint8ArrayToHex(flipUUIDByteOrder(hexToUint8Array(key.kid))); } } return keys; } } function getNestedProperty(object, nestedKeyName) { const keyNames = Array.isArray(nestedKeyName) ? nestedKeyName : nestedKeyName.split('.'); if (keyNames.length === 0) { return object; } let value = object; for (const keyName of keyNames) { value = value[keyName]; if (!value) { return null; } } return value; } function setNestedProperty(object, nestedKeyName, value) { const keyNames = Array.isArray(nestedKeyName) ? nestedKeyName : nestedKeyName.split('.'); if (keyNames.length === 0) { return object; } let cur = object; for (let i = 0; i < keyNames.length - 1; i++) { const keyName = keyNames[i]; if (typeof cur[keyName] !== 'object' || cur[keyName] === null) { cur[keyName] = {}; } cur = cur[keyName]; } cur[keyNames[keyNames.length - 1]] = value; return object; } // Only for Firefox-based browsers cuz this ext is MV3 function registerOverrideHeaders(overridingHeaders, urls) { try { const onBeforeSendHeaders = chrome.webRequest.onBeforeSendHeaders; if (registerOverrideHeaders._listener) { onBeforeSendHeaders.removeListener(registerOverrideHeaders._listener); delete registerOverrideHeaders._listener; } function overrideHeadersListener(details) { const requestHeaders = details.requestHeaders; for (const header in overridingHeaders) { const targetHeader = requestHeaders.find(h => h.name.toLowerCase() === header.toLowerCase()); if (targetHeader) { targetHeader.value = overridingHeaders[header]; } else { requestHeaders.push({ name: header, value: overridingHeaders[header] }); } } return { requestHeaders: requestHeaders }; } registerOverrideHeaders._listener = overrideHeadersListener; onBeforeSendHeaders.addListener( overrideHeadersListener, { urls: urls }, ["blocking", "requestHeaders"] ); } catch { // oh noes nerfed webRequest } } function getDefaultSGConfig(type) { if (type === "PLAYREADY") { return { "headers": { "Content-Type": "application/json", "X-Secret-Key": "{secret}" }, "generateChallenge": [ { "method": "GET", "url": "/{device_name}/open", "sessionIdResKeyName": "data.session_id" }, { "method": "POST", "url": "/{device_name}/get_license_challenge", "bodyObj": { "privacy_mode": true }, "sessionIdKeyName": "session_id", "psshKeyName": "init_data", "challengeKeyName": "data.challenge", "encodeB64": true, "bundleInKeyMessage": true } ], "parseLicense": [ { "method": "POST", "url": "/{device_name}/parse_license", "sessionIdKeyName": "session_id", "licenseKeyName": "license_message", "decodeB64": true }, { "method": "POST", "url": "/{device_name}/get_keys", "sessionIdKeyName": "session_id", "contentKeysKeyName": "data.keys" }, { "method": "GET", "url": "/{device_name}/close/%s" } ], "keyParseRules": { "keyKeyName": "key", "kidKeyName": "key_id" }, "messageKey": "message" } } else { return { "headers": { "Content-Type": "application/json", "X-Secret-Key": "{secret}" }, "generateChallenge": [ { "method": "GET", "url": "/{device_name}/open", "sessionIdResKeyName": "data.session_id" }, { "method": "POST", "url": "/{device_name}/set_service_certificate", "sessionIdKeyName": "session_id", "serverCertKeyName": "certificate", "serverCertOnly": true }, { "method": "POST", "url": "/{device_name}/get_license_challenge/AUTOMATIC", "bodyObj": { "privacy_mode": true }, "sessionIdKeyName": "session_id", "psshKeyName": "init_data", "challengeKeyName": "data.challenge_b64" } ], "parseLicense": [ { "method": "POST", "url": "/{device_name}/parse_license", "sessionIdKeyName": "session_id", "licenseKeyName": "license_message" }, { "method": "POST", "url": "/{device_name}/get_keys/CONTENT", "sessionIdKeyName": "session_id", "contentKeysKeyName": "data.keys" }, { "method": "GET", "url": "/{device_name}/close/%s" } ], "keyParseRules": { "keyKeyName": "key", "kidKeyName": "key_id" }, "messageKey": "message" } } }