From 4ac240c9dcdce68618954ee2f443997046268a89 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Tue, 27 Aug 2024 12:18:54 -0700 Subject: [PATCH] Prevent Widevine access request on "encrypted" w/o configured license url (#6644) * Gate use of Widevine PSSH on "encrypted" when config is missing corresponding license * Support playlist or "encrypted" key workflow with PlayReady --- src/controller/eme-controller.ts | 56 +++++++++++++++++++++++--------- src/loader/level-key.ts | 44 +++++-------------------- src/utils/mediakeys-helper.ts | 33 +++++++++++++++++++ 3 files changed, 81 insertions(+), 52 deletions(-) diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 5120bd9ef61..405ceea922c 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -12,10 +12,10 @@ import { keySystemDomainToKeySystemFormat as keySystemToKeySystemFormat, KeySystemFormats, keySystemFormatToKeySystemDomain, - KeySystemIds, keySystemIdToKeySystemDomain, KeySystems, requestMediaKeySystemAccess, + parsePlayReadyWRM, } from '../utils/mediakeys-helper'; import { strToUtf8array } from '../utils/utf8-utils'; import { base64Decode } from '../utils/numeric-encoding-utils'; @@ -25,8 +25,8 @@ import { bin2str, parseMultiPssh, parseSinf, - PsshData, - PsshInvalidResult, + type PsshData, + type PsshInvalidResult, } from '../utils/mp4-tools'; import { EventEmitter } from 'eventemitter3'; import type Hls from '../hls'; @@ -127,7 +127,7 @@ class EMEController extends Logger implements ComponentAPI { this.hls.off(Events.MANIFEST_LOADED, this.onManifestLoaded, this); } - private getLicenseServerUrl(keySystem: KeySystems): string | never { + private getLicenseServerUrl(keySystem: KeySystems): string | undefined { const { drmSystems, widevineLicenseUrl } = this.config; const keySystemConfiguration = drmSystems[keySystem]; @@ -139,10 +139,16 @@ class EMEController extends Logger implements ComponentAPI { if (keySystem === KeySystems.WIDEVINE && widevineLicenseUrl) { return widevineLicenseUrl; } + } - throw new Error( - `no license server URL configured for key-system "${keySystem}"`, - ); + private getLicenseServerUrlOrThrow(keySystem: KeySystems): string | never { + const url = this.getLicenseServerUrl(keySystem); + if (url === undefined) { + throw new Error( + `no license server URL configured for key-system "${keySystem}"`, + ); + } + return url; } private getServerCertificateUrl(keySystem: KeySystems): string | void { @@ -524,12 +530,12 @@ class EMEController extends Logger implements ComponentAPI { return; } - let keyId: Uint8Array | undefined; + let keyId: Uint8Array | null | undefined; let keySystemDomain: KeySystems | undefined; if ( initDataType === 'sinf' && - this.config.drmSystems[KeySystems.FAIRPLAY] + this.getLicenseServerUrl(KeySystems.FAIRPLAY) ) { // Match sinf keyId to playlist skd://keyId= const json = bin2str(new Uint8Array(initData)); @@ -547,12 +553,25 @@ class EMEController extends Logger implements ComponentAPI { this.warn(`${logMessage} Failed to parse sinf: ${error}`); return; } - } else { + } else if (this.getLicenseServerUrl(KeySystems.WIDEVINE)) { // Support Widevine clear-lead key-session creation (otherwise depend on playlist keys) const psshResults = parseMultiPssh(initData); - const psshInfo = psshResults.filter( - (pssh): pssh is PsshData => pssh.systemId === KeySystemIds.WIDEVINE, - )[0]; + + // TODO: If using keySystemAccessPromises we might want to wait until one is resolved + let keySystems = Object.keys( + this.keySystemAccessPromises, + ) as KeySystems[]; + if (!keySystems.length) { + keySystems = getKeySystemsForConfig(this.config); + } + + const psshInfo = psshResults.filter((pssh): pssh is PsshData => { + const keySystem = pssh.systemId + ? keySystemIdToKeySystemDomain(pssh.systemId) + : null; + return keySystem ? keySystems.indexOf(keySystem) > -1 : false; + })[0]; + if (!psshInfo) { if ( psshResults.length === 0 || @@ -568,10 +587,15 @@ class EMEController extends Logger implements ComponentAPI { } return; } + keySystemDomain = keySystemIdToKeySystemDomain(psshInfo.systemId); if (psshInfo.version === 0 && psshInfo.data) { - const offset = psshInfo.data.length - 22; - keyId = psshInfo.data.subarray(offset, offset + 16); + if (keySystemDomain === KeySystems.WIDEVINE) { + const offset = psshInfo.data.length - 22; + keyId = psshInfo.data.subarray(offset, offset + 16); + } else if (keySystemDomain === KeySystems.PLAYREADY) { + keyId = parsePlayReadyWRM(psshInfo.data); + } } } @@ -1098,7 +1122,7 @@ class EMEController extends Logger implements ComponentAPI { ): Promise { const keyLoadPolicy = this.config.keyLoadPolicy.default; return new Promise((resolve, reject) => { - const url = this.getLicenseServerUrl(keySessionContext.keySystem); + const url = this.getLicenseServerUrlOrThrow(keySessionContext.keySystem); this.log(`Sending license request to URL: ${url}`); const xhr = new XMLHttpRequest(); xhr.responseType = 'arraybuffer'; diff --git a/src/loader/level-key.ts b/src/loader/level-key.ts index cf853f57118..c625685e4af 100644 --- a/src/loader/level-key.ts +++ b/src/loader/level-key.ts @@ -1,12 +1,8 @@ -import { - changeEndianness, - convertDataUriToArrayBytes, -} from '../utils/keysystem-util'; +import { convertDataUriToArrayBytes } from '../utils/keysystem-util'; import { isFullSegmentEncryption } from '../utils/encryption-methods-util'; -import { KeySystemFormats } from '../utils/mediakeys-helper'; +import { KeySystemFormats, parsePlayReadyWRM } from '../utils/mediakeys-helper'; import { mp4pssh } from '../utils/mp4-tools'; import { logger } from '../utils/logger'; -import { base64Decode } from '../utils/numeric-encoding-utils'; let keyUriToKeyIdMap: { [uri: string]: Uint8Array } = {}; @@ -122,6 +118,8 @@ export class LevelKey implements DecryptData { if (keyBytes) { switch (this.keyFormat) { case KeySystemFormats.WIDEVINE: + // Setting `pssh` on this LevelKey/DecryptData allows HLS.js to generate a session using + // the playlist-key before the "encrypted" event. (Comment out to only use "encrypted" path.) this.pssh = keyBytes; // In case of widevine keyID is embedded in PSSH box. Read Key ID. if (keyBytes.length >= 22) { @@ -137,38 +135,12 @@ export class LevelKey implements DecryptData { 0x5b, 0xe0, 0x88, 0x5f, 0x95, ]); + // Setting `pssh` on this LevelKey/DecryptData allows HLS.js to generate a session using + // the playlist-key before the "encrypted" event. (Comment out to only use "encrypted" path.) this.pssh = mp4pssh(PlayReadyKeySystemUUID, null, keyBytes); - const keyBytesUtf16 = new Uint16Array( - keyBytes.buffer, - keyBytes.byteOffset, - keyBytes.byteLength / 2, - ); - const keyByteStr = String.fromCharCode.apply( - null, - Array.from(keyBytesUtf16), - ); - - // Parse Playready WRMHeader XML - const xmlKeyBytes = keyByteStr.substring( - keyByteStr.indexOf('<'), - keyByteStr.length, - ); - const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(xmlKeyBytes, 'text/xml'); - const keyData = xmlDoc.getElementsByTagName('KID')[0]; - if (keyData) { - const keyId = keyData.childNodes[0] - ? keyData.childNodes[0].nodeValue - : keyData.getAttribute('VALUE'); - if (keyId) { - const keyIdArray = base64Decode(keyId).subarray(0, 16); - // KID value in PRO is a base64-encoded little endian GUID interpretation of UUID - // KID value in ‘tenc’ is a big endian UUID GUID interpretation of UUID - changeEndianness(keyIdArray); - this.keyId = keyIdArray; - } - } + this.keyId = parsePlayReadyWRM(keyBytes); + break; } default: { diff --git a/src/utils/mediakeys-helper.ts b/src/utils/mediakeys-helper.ts index 426fb43a026..1e429493e1f 100755 --- a/src/utils/mediakeys-helper.ts +++ b/src/utils/mediakeys-helper.ts @@ -1,5 +1,7 @@ import type { DRMSystemOptions, EMEControllerConfig } from '../config'; import { optionalSelf } from './global'; +import { changeEndianness } from './keysystem-util'; +import { base64Decode } from './numeric-encoding-utils'; /** * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/requestMediaKeySystemAccess @@ -163,3 +165,34 @@ function createMediaKeySystemConfigurations( return [baseConfig]; } + +export function parsePlayReadyWRM(keyBytes: Uint8Array): Uint8Array | null { + const keyBytesUtf16 = new Uint16Array( + keyBytes.buffer, + keyBytes.byteOffset, + keyBytes.byteLength / 2, + ); + const keyByteStr = String.fromCharCode.apply(null, Array.from(keyBytesUtf16)); + + // Parse Playready WRMHeader XML + const xmlKeyBytes = keyByteStr.substring( + keyByteStr.indexOf('<'), + keyByteStr.length, + ); + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xmlKeyBytes, 'text/xml'); + const keyData = xmlDoc.getElementsByTagName('KID')[0]; + if (keyData) { + const keyId = keyData.childNodes[0] + ? keyData.childNodes[0].nodeValue + : keyData.getAttribute('VALUE'); + if (keyId) { + const keyIdArray = base64Decode(keyId).subarray(0, 16); + // KID value in PRO is a base64-encoded little endian GUID interpretation of UUID + // KID value in ‘tenc’ is a big endian UUID GUID interpretation of UUID + changeEndianness(keyIdArray); + return keyIdArray; + } + } + return null; +}