/** * @author Stephan Hesse | * * DRM support for Hls.js */ import { EventEmitter } from 'eventemitter3'; import { ErrorDetails, ErrorTypes } from '../errors'; import { Events } from '../events'; import { LevelKey } from '../loader/level-key'; import { arrayValuesMatch } from '../utils/arrays'; import { addEventListener, removeEventListener, } from '../utils/event-listener-helper'; import { arrayToHex } from '../utils/hex'; import { changeEndianness } from '../utils/keysystem-util'; import { Logger } from '../utils/logger'; import { getKeySystemsForConfig, getSupportedMediaKeySystemConfigurations, isPersistentSessionType, keySystemDomainToKeySystemFormat, keySystemFormatToKeySystemDomain, KeySystems, requestMediaKeySystemAccess, } from '../utils/mediakeys-helper'; import { bin2str, parseSinf } from '../utils/mp4-tools'; import { base64Decode } from '../utils/numeric-encoding-utils'; import { stringify } from '../utils/safe-json-stringify'; import { strToUtf8array } from '../utils/utf8-utils'; import type { EMEControllerConfig, HlsConfig, LoadPolicy } from '../config'; import type Hls from '../hls'; import type { Fragment } from '../loader/fragment'; import type { DecryptData } from '../loader/level-key'; import type { ComponentAPI } from '../types/component-api'; import type { ErrorData, KeyLoadedData, ManifestLoadedData, MediaAttachedData, } from '../types/events'; import type { Loader, LoaderCallbacks, LoaderConfiguration, LoaderContext, } from '../types/loader'; import type { KeySystemFormats } from '../utils/mediakeys-helper'; interface KeySystemAccessPromises { keySystemAccess: Promise; mediaKeys?: Promise; certificate?: Promise; hasMediaKeys?: boolean; } export interface MediaKeySessionContext { keySystem: KeySystems; mediaKeys: MediaKeys; decryptdata: LevelKey; mediaKeysSession: MediaKeySession; keyStatus?: MediaKeyStatus; keyStatusTimeouts?: { [keyId: string]: number }; licenseXhr?: XMLHttpRequest; _onmessage?: (this: MediaKeySession, ev: MediaKeyMessageEvent) => any; _onkeystatuseschange?: (this: MediaKeySession, ev: Event) => any; } /** * Controller to deal with encrypted media extensions (EME) * @see https://developer.mozilla.org/en-US/docs/Web/API/Encrypted_Media_Extensions_API * * @class * @constructor */ class EMEController extends Logger implements ComponentAPI { public static CDMCleanupPromise: Promise | void; private readonly hls: Hls; private readonly config: EMEControllerConfig & { loader: { new (confg: HlsConfig): Loader }; certLoadPolicy: LoadPolicy; keyLoadPolicy: LoadPolicy; }; private media: HTMLMediaElement | null = null; private mediaResolved?: () => void; private keyFormatPromise: Promise | null = null; private keySystemAccessPromises: { [keysystem: string]: KeySystemAccessPromises | undefined; } = {}; private _requestLicenseFailureCount: number = 0; private mediaKeySessions: MediaKeySessionContext[] = []; private keyIdToKeySessionPromise: { [keyId: string]: Promise | undefined; } = {}; private mediaKeys: MediaKeys | null = null; private setMediaKeysQueue: Promise[] = EMEController.CDMCleanupPromise ? [EMEController.CDMCleanupPromise] : []; private bannedKeyIds: { [keyId: string]: MediaKeyStatus | undefined } = {}; constructor(hls: Hls) { super('eme', hls.logger); this.hls = hls; this.config = hls.config; this.registerListeners(); } public destroy() { this.onDestroying(); this.onMediaDetached(); // Remove any references that could be held in config options or callbacks const config = this.config; config.requestMediaKeySystemAccessFunc = null; config.licenseXhrSetup = config.licenseResponseCallback = undefined; config.drmSystems = config.drmSystemOptions = {}; // @ts-ignore this.hls = this.config = this.keyIdToKeySessionPromise = null; // @ts-ignore this.onMediaEncrypted = this.onWaitingForKey = null; } private registerListeners() { this.hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this); this.hls.on(Events.MEDIA_DETACHED, this.onMediaDetached, this); this.hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this); this.hls.on(Events.MANIFEST_LOADED, this.onManifestLoaded, this); this.hls.on(Events.DESTROYING, this.onDestroying, this); } private unregisterListeners() { this.hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this); this.hls.off(Events.MEDIA_DETACHED, this.onMediaDetached, this); this.hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this); this.hls.off(Events.MANIFEST_LOADED, this.onManifestLoaded, this); this.hls.off(Events.DESTROYING, this.onDestroying, this); } private getLicenseServerUrl(keySystem: KeySystems): string | undefined { const { drmSystems, widevineLicenseUrl } = this.config; const keySystemConfiguration = drmSystems?.[keySystem]; if (keySystemConfiguration) { return keySystemConfiguration.licenseUrl; } // For backward compatibility if (keySystem === KeySystems.WIDEVINE && widevineLicenseUrl) { return widevineLicenseUrl; } } 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 { const { drmSystems } = this.config; const keySystemConfiguration = drmSystems?.[keySystem]; if (keySystemConfiguration) { return keySystemConfiguration.serverCertificateUrl; } else { this.log(`No Server Certificate in config.drmSystems["${keySystem}"]`); } } private attemptKeySystemAccess( keySystemsToAttempt: KeySystems[], ): Promise<{ keySystem: KeySystems; mediaKeys: MediaKeys }> { const levels = this.hls.levels; const uniqueCodec = (value: string | undefined, i, a): value is string => !!value && a.indexOf(value) === i; const audioCodecs = levels .map((level) => level.audioCodec) .filter(uniqueCodec); const videoCodecs = levels .map((level) => level.videoCodec) .filter(uniqueCodec); if (audioCodecs.length + videoCodecs.length === 0) { videoCodecs.push('avc1.42e01e'); } return new Promise( ( resolve: (result: { keySystem: KeySystems; mediaKeys: MediaKeys; }) => void, reject: (Error) => void, ) => { const attempt = (keySystems) => { const keySystem = keySystems.shift(); this.getMediaKeysPromise(keySystem, audioCodecs, videoCodecs) .then((mediaKeys) => resolve({ keySystem, mediaKeys })) .catch((error) => { if (keySystems.length) { attempt(keySystems); } else if (error instanceof EMEKeyError) { reject(error); } else { reject( new EMEKeyError( { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_NO_ACCESS, error, fatal: true, }, error.message, ), ); } }); }; attempt(keySystemsToAttempt); }, ); } private requestMediaKeySystemAccess( keySystem: KeySystems, supportedConfigurations: MediaKeySystemConfiguration[], ): Promise { const { requestMediaKeySystemAccessFunc } = this.config; if (!(typeof requestMediaKeySystemAccessFunc === 'function')) { let errMessage = `Configured requestMediaKeySystemAccess is not a function ${requestMediaKeySystemAccessFunc}`; if ( requestMediaKeySystemAccess === null && self.location.protocol === 'http:' ) { errMessage = `navigator.requestMediaKeySystemAccess is not available over insecure protocol ${location.protocol}`; } return Promise.reject(new Error(errMessage)); } return requestMediaKeySystemAccessFunc(keySystem, supportedConfigurations); } private getMediaKeysPromise( keySystem: KeySystems, audioCodecs: string[], videoCodecs: string[], ): Promise { // This can throw, but is caught in event handler callpath const mediaKeySystemConfigs = getSupportedMediaKeySystemConfigurations( keySystem, audioCodecs, videoCodecs, this.config.drmSystemOptions || {}, ); let keySystemAccessPromises = this.keySystemAccessPromises[keySystem]; let keySystemAccess = keySystemAccessPromises?.keySystemAccess; if (!keySystemAccess) { this.log( `Requesting encrypted media "${keySystem}" key-system access with config: ${stringify( mediaKeySystemConfigs, )}`, ); keySystemAccess = this.requestMediaKeySystemAccess( keySystem, mediaKeySystemConfigs, ); const keySystemAccessPromisesNew = (keySystemAccessPromises = this.keySystemAccessPromises[keySystem] = { keySystemAccess, }) as KeySystemAccessPromises; keySystemAccess.catch((error) => { this.log( `Failed to obtain access to key-system "${keySystem}": ${error}`, ); }); return keySystemAccess.then((mediaKeySystemAccess) => { this.log( `Access for key-system "${mediaKeySystemAccess.keySystem}" obtained`, ); const certificateRequest = this.fetchServerCertificate(keySystem); this.log(`Create media-keys for "${keySystem}"`); const mediaKeys = (keySystemAccessPromisesNew.mediaKeys = mediaKeySystemAccess.createMediaKeys().then((mediaKeys) => { this.log(`Media-keys created for "${keySystem}"`); keySystemAccessPromisesNew.hasMediaKeys = true; return certificateRequest.then((certificate) => { if (certificate) { return this.setMediaKeysServerCertificate( mediaKeys, keySystem, certificate, ); } return mediaKeys; }); })); mediaKeys.catch((error) => { this.error( `Failed to create media-keys for "${keySystem}"}: ${error}`, ); }); return mediaKeys; }); } return keySystemAccess.then(() => keySystemAccessPromises!.mediaKeys!); } private createMediaKeySessionContext({ decryptdata, keySystem, mediaKeys, }: { decryptdata: LevelKey; keySystem: KeySystems; mediaKeys: MediaKeys; }): MediaKeySessionContext { this.log( `Creating key-system session "${keySystem}" keyId: ${arrayToHex( decryptdata.keyId || ([] as number[]), )} keyUri: ${decryptdata.uri}`, ); const mediaKeysSession = mediaKeys.createSession(); const mediaKeySessionContext: MediaKeySessionContext = { decryptdata, keySystem, mediaKeys, mediaKeysSession, keyStatus: 'status-pending', }; this.mediaKeySessions.push(mediaKeySessionContext); return mediaKeySessionContext; } private renewKeySession(mediaKeySessionContext: MediaKeySessionContext) { const decryptdata = mediaKeySessionContext.decryptdata; if (decryptdata.pssh) { const keySessionContext = this.createMediaKeySessionContext( mediaKeySessionContext, ); const keyId = getKeyIdString(decryptdata); const scheme = 'cenc'; this.keyIdToKeySessionPromise[keyId] = this.generateRequestWithPreferredKeySession( keySessionContext, scheme, decryptdata.pssh.buffer, 'expired', ); } else { this.warn(`Could not renew expired session. Missing pssh initData.`); } // eslint-disable-next-line @typescript-eslint/no-floating-promises this.removeSession(mediaKeySessionContext); } private updateKeySession( mediaKeySessionContext: MediaKeySessionContext, data: Uint8Array, ): Promise { const keySession = mediaKeySessionContext.mediaKeysSession; this.log( `Updating key-session "${keySession.sessionId}" for keyId ${arrayToHex( mediaKeySessionContext.decryptdata.keyId || [], )} } (data length: ${data.byteLength})`, ); return keySession.update(data); } public getSelectedKeySystemFormats(): KeySystemFormats[] { return (Object.keys(this.keySystemAccessPromises) as KeySystems[]) .map((keySystem) => ({ keySystem, hasMediaKeys: this.keySystemAccessPromises[keySystem]!.hasMediaKeys, })) .filter(({ hasMediaKeys }) => !!hasMediaKeys) .map(({ keySystem }) => keySystemDomainToKeySystemFormat(keySystem)) .filter((keySystem): keySystem is KeySystemFormats => !!keySystem); } public getKeySystemAccess(keySystemsToAttempt: KeySystems[]): Promise { return this.getKeySystemSelectionPromise(keySystemsToAttempt).then( ({ keySystem, mediaKeys }) => { return this.attemptSetMediaKeys(keySystem, mediaKeys); }, ); } public selectKeySystem( keySystemsToAttempt: KeySystems[], ): Promise { return new Promise((resolve, reject) => { this.getKeySystemSelectionPromise(keySystemsToAttempt) .then(({ keySystem }) => { const keySystemFormat = keySystemDomainToKeySystemFormat(keySystem); if (keySystemFormat) { resolve(keySystemFormat); } else { reject( new Error(`Unable to find format for key-system "${keySystem}"`), ); } }) .catch(reject); }); } public selectKeySystemFormat(frag: Fragment): Promise { const keyFormats = Object.keys(frag.levelkeys || {}) as KeySystemFormats[]; if (!this.keyFormatPromise) { this.log( `Selecting key-system from fragment (sn: ${frag.sn} ${frag.type}: ${ frag.level }) key formats ${keyFormats.join(', ')}`, ); this.keyFormatPromise = this.getKeyFormatPromise(keyFormats); } return this.keyFormatPromise; } private getKeyFormatPromise( keyFormats: KeySystemFormats[], ): Promise { const keySystemsInConfig = getKeySystemsForConfig(this.config); const keySystemsToAttempt = keyFormats .map(keySystemFormatToKeySystemDomain) .filter( (value) => !!value && keySystemsInConfig.indexOf(value) !== -1, ) as any as KeySystems[]; return this.selectKeySystem(keySystemsToAttempt); } public getKeyStatus(decryptdata: LevelKey): MediaKeyStatus | undefined { const { mediaKeySessions } = this; for (let i = 0; i < mediaKeySessions.length; i++) { const status = getKeyStatus(decryptdata, mediaKeySessions[i]); if (status) { return status; } } return undefined; } public loadKey(data: KeyLoadedData): Promise { const decryptdata = data.keyInfo.decryptdata; const keyId = getKeyIdString(decryptdata); const badStatus = this.bannedKeyIds[keyId]; if (badStatus || this.getKeyStatus(decryptdata) === 'internal-error') { const error = getKeyStatusError( badStatus || 'internal-error', decryptdata, ); this.handleError(error, data.frag); return Promise.reject(error); } const keyDetails = `(keyId: ${keyId} format: "${decryptdata.keyFormat}" method: ${decryptdata.method} uri: ${decryptdata.uri})`; this.log(`Starting session for key ${keyDetails}`); const keyContextPromise = this.keyIdToKeySessionPromise[keyId]; if (!keyContextPromise) { const keySessionContextPromise = this.getKeySystemForKeyPromise( decryptdata, ) .then(({ keySystem, mediaKeys }) => { this.throwIfDestroyed(); this.log( `Handle encrypted media sn: ${data.frag.sn} ${data.frag.type}: ${data.frag.level} using key ${keyDetails}`, ); return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => { this.throwIfDestroyed(); return this.createMediaKeySessionContext({ keySystem, mediaKeys, decryptdata, }); }); }) .then((keySessionContext) => { const scheme = 'cenc'; const initData = decryptdata.pssh ? decryptdata.pssh.buffer : null; return this.generateRequestWithPreferredKeySession( keySessionContext, scheme, initData, 'playlist-key', ); }); keySessionContextPromise.catch((error) => this.handleError(error, data.frag), ); this.keyIdToKeySessionPromise[keyId] = keySessionContextPromise; return keySessionContextPromise; } // Re-emit error for playlist key loading keyContextPromise.catch((error) => { if (error instanceof EMEKeyError) { const errorData = { ...error.data }; if (this.getKeyStatus(decryptdata) === 'internal-error') { errorData.decryptdata = decryptdata; } const clonedError = new EMEKeyError(errorData, error.message); this.handleError(clonedError, data.frag); } }); return keyContextPromise; } private throwIfDestroyed(message = 'Invalid state'): void | never { if (!this.hls as any) { throw new Error('invalid state'); } } private handleError(error: EMEKeyError | Error, frag?: Fragment) { if (!this.hls as any) { return; } if (error instanceof EMEKeyError) { if (frag) { error.data.frag = frag; } const levelKey = error.data.decryptdata; this.error( `${error.message}${ levelKey ? ` (${arrayToHex(levelKey.keyId || [])})` : '' }`, ); this.hls.trigger(Events.ERROR, error.data); } else { this.error(error.message); this.hls.trigger(Events.ERROR, { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_NO_KEYS, error, fatal: true, }); } } private getKeySystemForKeyPromise( decryptdata: LevelKey, ): Promise<{ keySystem: KeySystems; mediaKeys: MediaKeys }> { const keyId = getKeyIdString(decryptdata); const mediaKeySessionContext = this.keyIdToKeySessionPromise[keyId]; if (!mediaKeySessionContext) { const keySystem = keySystemFormatToKeySystemDomain( decryptdata.keyFormat as KeySystemFormats, ); const keySystemsToAttempt = keySystem ? [keySystem] : getKeySystemsForConfig(this.config); return this.attemptKeySystemAccess(keySystemsToAttempt); } return mediaKeySessionContext; } private getKeySystemSelectionPromise( keySystemsToAttempt: KeySystems[], ): Promise<{ keySystem: KeySystems; mediaKeys: MediaKeys }> | never { if (!keySystemsToAttempt.length) { keySystemsToAttempt = getKeySystemsForConfig(this.config); } if (keySystemsToAttempt.length === 0) { throw new EMEKeyError( { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_NO_CONFIGURED_LICENSE, fatal: true, }, `Missing key-system license configuration options ${stringify({ drmSystems: this.config.drmSystems, })}`, ); } return this.attemptKeySystemAccess(keySystemsToAttempt); } private onMediaEncrypted = (event: MediaEncryptedEvent) => { const { initDataType, initData } = event; const logMessage = `"${event.type}" event: init data type: "${initDataType}"`; this.debug(logMessage); // Ignore event when initData is null if (initData === null) { return; } if (!this.keyFormatPromise) { let keySystems = Object.keys( this.keySystemAccessPromises, ) as KeySystems[]; if (!keySystems.length) { keySystems = getKeySystemsForConfig(this.config); } const keyFormats = keySystems .map(keySystemDomainToKeySystemFormat) .filter((k) => !!k) as KeySystemFormats[]; this.keyFormatPromise = this.getKeyFormatPromise(keyFormats); } this.keyFormatPromise .then((keySystemFormat) => { const keySystem = keySystemFormatToKeySystemDomain(keySystemFormat); if (initDataType !== 'sinf' || keySystem !== KeySystems.FAIRPLAY) { this.log( `Ignoring "${event.type}" event with init data type: "${initDataType}" for selected key-system ${keySystem}`, ); return; } // Match sinf keyId to playlist skd://keyId= let keyId: Uint8Array | undefined; try { const json = bin2str(new Uint8Array(initData)); const sinf = base64Decode(JSON.parse(json).sinf); const tenc = parseSinf(sinf); if (!tenc) { throw new Error( `'schm' box missing or not cbcs/cenc with schi > tenc`, ); } keyId = new Uint8Array(tenc.subarray(8, 24)); } catch (error) { this.warn(`${logMessage} Failed to parse sinf: ${error}`); return; } const keyIdHex = arrayToHex(keyId); const { keyIdToKeySessionPromise, mediaKeySessions } = this; let keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex]; for (let i = 0; i < mediaKeySessions.length; i++) { // Match playlist key const keyContext = mediaKeySessions[i]; const decryptdata = keyContext.decryptdata; if (!decryptdata.keyId) { continue; } const oldKeyIdHex = arrayToHex(decryptdata.keyId); if ( arrayValuesMatch(keyId, decryptdata.keyId) || decryptdata.uri.replace(/-/g, '').indexOf(keyIdHex) !== -1 ) { keySessionContextPromise = keyIdToKeySessionPromise[oldKeyIdHex]; if (!keySessionContextPromise) { continue; } if (decryptdata.pssh) { break; } delete keyIdToKeySessionPromise[oldKeyIdHex]; decryptdata.pssh = new Uint8Array(initData); decryptdata.keyId = keyId; keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex] = keySessionContextPromise.then(() => { return this.generateRequestWithPreferredKeySession( keyContext, initDataType, initData, 'encrypted-event-key-match', ); }); keySessionContextPromise.catch((error) => this.handleError(error)); break; } } if (!keySessionContextPromise) { this.handleError( new Error( `Key ID ${keyIdHex} not encountered in playlist. Key-system sessions ${mediaKeySessions.length}.`, ), ); } }) .catch((error) => this.handleError(error)); }; private onWaitingForKey = (event: Event) => { this.log(`"${event.type}" event`); }; private attemptSetMediaKeys( keySystem: KeySystems, mediaKeys: MediaKeys, ): Promise { this.mediaResolved = undefined; if (this.mediaKeys === mediaKeys) { return Promise.resolve(); } const queue = this.setMediaKeysQueue.slice(); this.log(`Setting media-keys for "${keySystem}"`); // Only one setMediaKeys() can run at one time, and multiple setMediaKeys() operations // can be queued for execution for multiple key sessions. const setMediaKeysPromise = Promise.all(queue).then(() => { if (!this.media) { return new Promise((resolve: (value?: void) => void, reject) => { this.mediaResolved = () => { this.mediaResolved = undefined; if (!this.media) { return reject( new Error( 'Attempted to set mediaKeys without media element attached', ), ); } this.mediaKeys = mediaKeys; this.media.setMediaKeys(mediaKeys).then(resolve).catch(reject); }; }); } return this.media.setMediaKeys(mediaKeys); }); this.mediaKeys = mediaKeys; this.setMediaKeysQueue.push(setMediaKeysPromise); return setMediaKeysPromise.then(() => { this.log(`Media-keys set for "${keySystem}"`); queue.push(setMediaKeysPromise!); this.setMediaKeysQueue = this.setMediaKeysQueue.filter( (p) => queue.indexOf(p) === -1, ); }); } private generateRequestWithPreferredKeySession( context: MediaKeySessionContext, initDataType: string, initData: ArrayBuffer | null, reason: | 'playlist-key' | 'encrypted-event-key-match' | 'encrypted-event-no-match' | 'expired', ): Promise | never { const generateRequestFilter = this.config.drmSystems?.[context.keySystem]?.generateRequest; if (generateRequestFilter) { try { const mappedInitData: ReturnType = generateRequestFilter.call(this.hls, initDataType, initData, context); if (!mappedInitData) { throw new Error( 'Invalid response from configured generateRequest filter', ); } initDataType = mappedInitData.initDataType; initData = mappedInitData.initData ? mappedInitData.initData : null; context.decryptdata.pssh = initData ? new Uint8Array(initData) : null; } catch (error) { this.warn(error.message); if ((this.hls as any) && this.hls.config.debug) { throw error; } } } if (initData === null) { this.log(`Skipping key-session request for "${reason}" (no initData)`); return Promise.resolve(context); } const keyId = getKeyIdString(context.decryptdata); const keyUri = context.decryptdata.uri; this.log( `Generating key-session request for "${reason}" keyId: ${keyId} URI: ${keyUri} (init data type: ${initDataType} length: ${ initData.byteLength })`, ); const licenseStatus = new EventEmitter(); const onmessage = (context._onmessage = (event: MediaKeyMessageEvent) => { const keySession = context.mediaKeysSession; if (!keySession as any) { licenseStatus.emit('error', new Error('invalid state')); return; } const { messageType, message } = event; this.log( `"${messageType}" message event for session "${keySession.sessionId}" message size: ${message.byteLength}`, ); if ( messageType === 'license-request' || messageType === 'license-renewal' ) { this.renewLicense(context, message).catch((error) => { if (licenseStatus.eventNames().length) { licenseStatus.emit('error', error); } else { this.handleError(error); } }); } else if (messageType === 'license-release') { if (context.keySystem === KeySystems.FAIRPLAY) { this.updateKeySession(context, strToUtf8array('acknowledged')) .then(() => this.removeSession(context)) .catch((error) => this.handleError(error)); } } else { this.warn(`unhandled media key message type "${messageType}"`); } }); const handleKeyStatus = ( keyStatus: MediaKeyStatus, context: MediaKeySessionContext, ) => { context.keyStatus = keyStatus; let keyError: EMEKeyError | Error | undefined; if (keyStatus.startsWith('usable')) { licenseStatus.emit('resolved'); } else if ( keyStatus === 'internal-error' || keyStatus === 'output-restricted' || keyStatus === 'output-downscaled' ) { keyError = getKeyStatusError(keyStatus, context.decryptdata); } else if (keyStatus === 'expired') { keyError = new Error(`key expired (keyId: ${keyId})`); } else if (keyStatus === 'released') { keyError = new Error(`key released`); } else if (keyStatus === 'status-pending') { /* no-op */ } else { this.warn( `unhandled key status change "${keyStatus}" (keyId: ${keyId})`, ); } if (keyError) { if (licenseStatus.eventNames().length) { licenseStatus.emit('error', keyError); } else { this.handleError(keyError); } } }; const onkeystatuseschange = (context._onkeystatuseschange = ( event: Event, ) => { const keySession = context.mediaKeysSession; if (!keySession as any) { licenseStatus.emit('error', new Error('invalid state')); return; } const keyStatuses = this.getKeyStatuses(context); const keyIds = Object.keys(keyStatuses); // exit if all keys are status-pending if (!keyIds.some((id) => keyStatuses[id] !== 'status-pending')) { return; } // renew when a key status for a levelKey comes back expired if (keyStatuses[keyId] === 'expired') { // renew when a key status comes back expired this.log( `Expired key ${stringify(keyStatuses)} in key-session "${context.mediaKeysSession.sessionId}"`, ); this.renewKeySession(context); return; } let keyStatus = keyStatuses[keyId] as MediaKeyStatus | undefined; if (keyStatus) { // handle status of current key handleKeyStatus(keyStatus, context); } else { // Timeout key-status const timeout = 1000; context.keyStatusTimeouts ||= {}; context.keyStatusTimeouts[keyId] ||= self.setTimeout(() => { if ((!context.mediaKeysSession as any) || !this.mediaKeys) { return; } // Find key status in another session if missing (PlayReady #7519 no key-status "single-key" setup with shared key) const sessionKeyStatus = this.getKeyStatus(context.decryptdata); if (sessionKeyStatus && sessionKeyStatus !== 'status-pending') { this.log( `No status for keyId ${keyId} in key-session "${context.mediaKeysSession.sessionId}". Using session key-status ${sessionKeyStatus} from other session.`, ); return handleKeyStatus(sessionKeyStatus, context); } // Timeout key with internal-error this.log( `key status for ${keyId} in key-session "${context.mediaKeysSession.sessionId}" timed out after ${timeout}ms`, ); keyStatus = 'internal-error'; handleKeyStatus(keyStatus, context); }, timeout); this.log(`No status for keyId ${keyId} (${stringify(keyStatuses)}).`); } }); addEventListener(context.mediaKeysSession, 'message', onmessage); addEventListener( context.mediaKeysSession, 'keystatuseschange', onkeystatuseschange, ); const keyUsablePromise = new Promise( (resolve: (value?: void) => void, reject) => { licenseStatus.on('error', reject); licenseStatus.on('resolved', resolve); }, ); return context.mediaKeysSession .generateRequest(initDataType, initData) .then(() => { this.log( `Request generated for key-session "${context.mediaKeysSession.sessionId}" keyId: ${keyId} URI: ${keyUri}`, ); }) .catch((error) => { throw new EMEKeyError( { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_NO_SESSION, error, decryptdata: context.decryptdata, fatal: false, }, `Error generating key-session request: ${error}`, ); }) .then(() => keyUsablePromise) .catch((error) => { licenseStatus.removeAllListeners(); return this.removeSession(context).then(() => { throw error; }); }) .then(() => { licenseStatus.removeAllListeners(); return context; }); } private getKeyStatuses(mediaKeySessionContext: MediaKeySessionContext): { [keyId: string]: MediaKeyStatus; } { const keyStatuses: { [keyId: string]: MediaKeyStatus } = {}; mediaKeySessionContext.mediaKeysSession.keyStatuses.forEach( (status: MediaKeyStatus, keyId: BufferSource) => { // keyStatuses.forEach is not standard API so the callback value looks weird on xboxone // xboxone callback(keyId, status) so we need to exchange them if (typeof keyId === 'string' && typeof status === 'object') { const temp = keyId; keyId = status; status = temp; } const keyIdArray = 'buffer' in keyId ? new Uint8Array(keyId.buffer, keyId.byteOffset, keyId.byteLength) : new Uint8Array(keyId); if ( mediaKeySessionContext.keySystem === KeySystems.PLAYREADY && keyIdArray.length === 16 ) { // On some devices, the key ID has already been converted for endianness. // In such cases, this key ID is the one we need to cache. const originKeyIdWithStatusChange = arrayToHex(keyIdArray); // Cache the original key IDs to ensure compatibility across all cases. keyStatuses[originKeyIdWithStatusChange] = status; changeEndianness(keyIdArray); } const keyIdWithStatusChange = arrayToHex(keyIdArray); // Add to banned keys to prevent playlist usage and license requests if (status === 'internal-error') { this.bannedKeyIds[keyIdWithStatusChange] = status; } this.log( `key status change "${status}" for keyStatuses keyId: ${keyIdWithStatusChange} key-session "${mediaKeySessionContext.mediaKeysSession.sessionId}"`, ); keyStatuses[keyIdWithStatusChange] = status; }, ); return keyStatuses; } private fetchServerCertificate( keySystem: KeySystems, ): Promise { const config = this.config; const Loader = config.loader; const certLoader = new Loader(config as HlsConfig) as Loader; const url = this.getServerCertificateUrl(keySystem); if (!url) { return Promise.resolve(); } this.log(`Fetching server certificate for "${keySystem}"`); return new Promise((resolve, reject) => { const loaderContext: LoaderContext = { responseType: 'arraybuffer', url, }; const loadPolicy = config.certLoadPolicy.default; const loaderConfig: LoaderConfiguration = { loadPolicy, timeout: loadPolicy.maxLoadTimeMs, maxRetry: 0, retryDelay: 0, maxRetryDelay: 0, }; const loaderCallbacks: LoaderCallbacks = { onSuccess: (response, stats, context, networkDetails) => { resolve(response.data as ArrayBuffer); }, onError: (response, contex, networkDetails, stats) => { reject( new EMEKeyError( { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED, fatal: true, networkDetails, response: { url: loaderContext.url, data: undefined, ...response, }, }, `"${keySystem}" certificate request failed (${url}). Status: ${response.code} (${response.text})`, ), ); }, onTimeout: (stats, context, networkDetails) => { reject( new EMEKeyError( { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED, fatal: true, networkDetails, response: { url: loaderContext.url, data: undefined, }, }, `"${keySystem}" certificate request timed out (${url})`, ), ); }, onAbort: (stats, context, networkDetails) => { reject(new Error('aborted')); }, }; certLoader.load(loaderContext, loaderConfig, loaderCallbacks); }); } private setMediaKeysServerCertificate( mediaKeys: MediaKeys, keySystem: KeySystems, cert: BufferSource, ): Promise { return new Promise((resolve, reject) => { mediaKeys .setServerCertificate(cert) .then((success) => { this.log( `setServerCertificate ${ success ? 'success' : 'not supported by CDM' } (${cert.byteLength}) on "${keySystem}"`, ); resolve(mediaKeys); }) .catch((error) => { reject( new EMEKeyError( { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED, error, fatal: true, }, error.message, ), ); }); }); } private renewLicense( context: MediaKeySessionContext, keyMessage: ArrayBuffer, ): Promise { return this.requestLicense(context, new Uint8Array(keyMessage)).then( (data: ArrayBuffer) => { return this.updateKeySession(context, new Uint8Array(data)).catch( (error) => { throw new EMEKeyError( { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED, decryptdata: context.decryptdata, error, fatal: false, }, error.message, ); }, ); }, ); } private unpackPlayReadyKeyMessage( xhr: XMLHttpRequest, licenseChallenge: Uint8Array, ): Uint8Array { // On Edge, the raw license message is UTF-16-encoded XML. We need // to unpack the Challenge element (base64-encoded string containing the // actual license request) and any HttpHeader elements (sent as request // headers). // For PlayReady CDMs, we need to dig the Challenge out of the XML. const xmlString = String.fromCharCode.apply( null, new Uint16Array(licenseChallenge.buffer), ); if (!xmlString.includes('PlayReadyKeyMessage')) { // This does not appear to be a wrapped message as on Edge. Some // clients do not need this unwrapping, so we will assume this is one of // them. Note that "xml" at this point probably looks like random // garbage, since we interpreted UTF-8 as UTF-16. xhr.setRequestHeader('Content-Type', 'text/xml; charset=utf-8'); return licenseChallenge; } const keyMessageXml = new DOMParser().parseFromString( xmlString, 'application/xml', ); // Set request headers. const headers = keyMessageXml.querySelectorAll('HttpHeader'); if (headers.length > 0) { let header: Element; for (let i = 0, len = headers.length; i < len; i++) { header = headers[i]; const name = header.querySelector('name')?.textContent; const value = header.querySelector('value')?.textContent; if (name && value) { xhr.setRequestHeader(name, value); } } } const challengeElement = keyMessageXml.querySelector('Challenge'); const challengeText = challengeElement?.textContent; if (!challengeText) { throw new Error(`Cannot find in key message`); } return strToUtf8array(atob(challengeText)); } private setupLicenseXHR( xhr: XMLHttpRequest, url: string, keysListItem: MediaKeySessionContext, licenseChallenge: Uint8Array, ): Promise<{ xhr: XMLHttpRequest; licenseChallenge: Uint8Array; }> { const licenseXhrSetup = this.config.licenseXhrSetup; if (!licenseXhrSetup) { xhr.open('POST', url, true); return Promise.resolve({ xhr, licenseChallenge }); } return Promise.resolve() .then(() => { if (!keysListItem.decryptdata as any) { throw new Error('Key removed'); } return licenseXhrSetup.call( this.hls, xhr, url, keysListItem, licenseChallenge, ); }) .catch((error: Error) => { if (!keysListItem.decryptdata as any) { // Key session removed. Cancel license request. throw error; } // let's try to open before running setup xhr.open('POST', url, true); return licenseXhrSetup.call( this.hls, xhr, url, keysListItem, licenseChallenge, ); }) .then((licenseXhrSetupResult) => { // if licenseXhrSetup did not yet call open, let's do it now if (!xhr.readyState) { xhr.open('POST', url, true); } const finalLicenseChallenge = licenseXhrSetupResult ? licenseXhrSetupResult : licenseChallenge; return { xhr, licenseChallenge: finalLicenseChallenge }; }); } private requestLicense( keySessionContext: MediaKeySessionContext, licenseChallenge: Uint8Array, ): Promise { const keyLoadPolicy = this.config.keyLoadPolicy.default; return new Promise((resolve, reject) => { const url = this.getLicenseServerUrlOrThrow(keySessionContext.keySystem); this.log(`Sending license request to URL: ${url}`); const xhr = new XMLHttpRequest(); xhr.responseType = 'arraybuffer'; xhr.onreadystatechange = () => { if ( (!this.hls as any) || (!keySessionContext.mediaKeysSession as any) ) { return reject(new Error('invalid state')); } if (xhr.readyState === 4) { if (xhr.status === 200) { this._requestLicenseFailureCount = 0; let data = xhr.response; this.log( `License received ${ data instanceof ArrayBuffer ? data.byteLength : data }`, ); const licenseResponseCallback = this.config.licenseResponseCallback; if (licenseResponseCallback) { try { data = licenseResponseCallback.call( this.hls, xhr, url, keySessionContext, ); } catch (error) { this.error(error); } } resolve(data); } else { const retryConfig = keyLoadPolicy.errorRetry; const maxNumRetry = retryConfig ? retryConfig.maxNumRetry : 0; this._requestLicenseFailureCount++; if ( this._requestLicenseFailureCount > maxNumRetry || (xhr.status >= 400 && xhr.status < 500) ) { reject( new EMEKeyError( { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, decryptdata: keySessionContext.decryptdata, fatal: true, networkDetails: xhr, response: { url, data: undefined as any, code: xhr.status, text: xhr.statusText, }, }, `License Request XHR failed (${url}). Status: ${xhr.status} (${xhr.statusText})`, ), ); } else { const attemptsLeft = maxNumRetry - this._requestLicenseFailureCount + 1; this.warn( `Retrying license request, ${attemptsLeft} attempts left`, ); this.requestLicense(keySessionContext, licenseChallenge).then( resolve, reject, ); } } } }; if ( keySessionContext.licenseXhr && keySessionContext.licenseXhr.readyState !== XMLHttpRequest.DONE ) { keySessionContext.licenseXhr.abort(); } keySessionContext.licenseXhr = xhr; this.setupLicenseXHR(xhr, url, keySessionContext, licenseChallenge) .then(({ xhr, licenseChallenge }) => { if (keySessionContext.keySystem == KeySystems.PLAYREADY) { licenseChallenge = this.unpackPlayReadyKeyMessage( xhr, licenseChallenge, ); } xhr.send(licenseChallenge); }) .catch(reject); }); } private onDestroying() { this.unregisterListeners(); this._clear(); } private onMediaAttached( event: Events.MEDIA_ATTACHED, data: MediaAttachedData, ) { if (!this.config.emeEnabled) { return; } const media = data.media; // keep reference of media this.media = media; addEventListener(media, 'encrypted', this.onMediaEncrypted); addEventListener(media, 'waitingforkey', this.onWaitingForKey); const mediaResolved = this.mediaResolved; if (mediaResolved) { mediaResolved(); } else { this.mediaKeys = media.mediaKeys; } } private onMediaDetached() { const media = this.media; if (media) { removeEventListener(media, 'encrypted', this.onMediaEncrypted); removeEventListener(media, 'waitingforkey', this.onWaitingForKey); this.media = null; this.mediaKeys = null; } } private _clear() { this._requestLicenseFailureCount = 0; this.keyIdToKeySessionPromise = {}; this.bannedKeyIds = {}; const mediaResolved = this.mediaResolved; if (mediaResolved) { mediaResolved(); } if (!this.mediaKeys && !this.mediaKeySessions.length) { return; } const media = this.media; const mediaKeysList = this.mediaKeySessions.slice(); this.mediaKeySessions = []; this.mediaKeys = null; LevelKey.clearKeyUriToKeyIdMap(); // Close all sessions and remove media keys from the video element. const keySessionCount = mediaKeysList.length; EMEController.CDMCleanupPromise = Promise.all( mediaKeysList .map((mediaKeySessionContext) => this.removeSession(mediaKeySessionContext), ) .concat( (media?.setMediaKeys(null) as Promise | null)?.catch( (error) => { this.log(`Could not clear media keys: ${error}`); if (!this.hls as any) return; this.hls.trigger(Events.ERROR, { type: ErrorTypes.OTHER_ERROR, details: ErrorDetails.KEY_SYSTEM_DESTROY_MEDIA_KEYS_ERROR, fatal: false, error: new Error(`Could not clear media keys: ${error}`), }); }, ) || Promise.resolve(), ), ) .catch((error) => { this.log(`Could not close sessions and clear media keys: ${error}`); if (!this.hls as any) return; this.hls.trigger(Events.ERROR, { type: ErrorTypes.OTHER_ERROR, details: ErrorDetails.KEY_SYSTEM_DESTROY_CLOSE_SESSION_ERROR, fatal: false, error: new Error( `Could not close sessions and clear media keys: ${error}`, ), }); }) .then(() => { if (keySessionCount) { this.log('finished closing key sessions and clearing media keys'); } }); } private onManifestLoading() { this._clear(); } private onManifestLoaded( event: Events.MANIFEST_LOADED, { sessionKeys }: ManifestLoadedData, ) { if (!sessionKeys || !this.config.emeEnabled) { return; } if (!this.keyFormatPromise) { const keyFormats: KeySystemFormats[] = sessionKeys.reduce( (formats: KeySystemFormats[], sessionKey: LevelKey) => { if ( formats.indexOf(sessionKey.keyFormat as KeySystemFormats) === -1 ) { formats.push(sessionKey.keyFormat as KeySystemFormats); } return formats; }, [], ); this.log( `Selecting key-system from session-keys ${keyFormats.join(', ')}`, ); this.keyFormatPromise = this.getKeyFormatPromise(keyFormats); } } private removeSession( mediaKeySessionContext: MediaKeySessionContext, ): Promise { const { mediaKeysSession, licenseXhr, decryptdata } = mediaKeySessionContext; if (mediaKeysSession as MediaKeySession | undefined) { this.log( `Remove licenses and keys and close session "${mediaKeysSession.sessionId}" keyId: ${arrayToHex((decryptdata as LevelKey | undefined)?.keyId || [])}`, ); if (mediaKeySessionContext._onmessage) { mediaKeysSession.removeEventListener( 'message', mediaKeySessionContext._onmessage, ); mediaKeySessionContext._onmessage = undefined; } if (mediaKeySessionContext._onkeystatuseschange) { mediaKeysSession.removeEventListener( 'keystatuseschange', mediaKeySessionContext._onkeystatuseschange, ); mediaKeySessionContext._onkeystatuseschange = undefined; } if (licenseXhr && licenseXhr.readyState !== XMLHttpRequest.DONE) { licenseXhr.abort(); } mediaKeySessionContext.mediaKeysSession = mediaKeySessionContext.decryptdata = mediaKeySessionContext.licenseXhr = undefined!; const index = this.mediaKeySessions.indexOf(mediaKeySessionContext); if (index > -1) { this.mediaKeySessions.splice(index, 1); } const { keyStatusTimeouts } = mediaKeySessionContext; if (keyStatusTimeouts) { Object.keys(keyStatusTimeouts).forEach((keyId) => self.clearTimeout(keyStatusTimeouts[keyId]), ); } const { drmSystemOptions } = this.config; const removePromise = isPersistentSessionType(drmSystemOptions) ? new Promise((resolve, reject) => { self.setTimeout( () => reject(new Error(`MediaKeySession.remove() timeout`)), 8000, ); mediaKeysSession.remove().then(resolve).catch(reject); }) : Promise.resolve(); return removePromise .catch((error) => { this.log(`Could not remove session: ${error}`); if (!this.hls as any) return; this.hls.trigger(Events.ERROR, { type: ErrorTypes.OTHER_ERROR, details: ErrorDetails.KEY_SYSTEM_DESTROY_REMOVE_SESSION_ERROR, fatal: false, error: new Error(`Could not remove session: ${error}`), }); }) .then(() => { return mediaKeysSession.close(); }) .catch((error) => { this.log(`Could not close session: ${error}`); if (!this.hls as any) return; this.hls.trigger(Events.ERROR, { type: ErrorTypes.OTHER_ERROR, details: ErrorDetails.KEY_SYSTEM_DESTROY_CLOSE_SESSION_ERROR, fatal: false, error: new Error(`Could not close session: ${error}`), }); }); } return Promise.resolve(); } } function getKeyIdString(decryptdata: DecryptData | undefined): string | never { if (!decryptdata) { throw new Error('Could not read keyId of undefined decryptdata'); } if (decryptdata.keyId === null) { throw new Error('keyId is null'); } return arrayToHex(decryptdata.keyId); } function getKeyStatus( decryptdata: LevelKey, keyContext: MediaKeySessionContext, ): MediaKeyStatus | undefined { if ( decryptdata.keyId && keyContext.mediaKeysSession.keyStatuses.has(decryptdata.keyId) ) { return keyContext.mediaKeysSession.keyStatuses.get(decryptdata.keyId); } if (decryptdata.matches(keyContext.decryptdata)) { return keyContext.keyStatus; } return undefined; } export class EMEKeyError extends Error { public readonly data: ErrorData; constructor( data: Omit & { error?: Error }, message: string, ) { super(message); data.error ||= new Error(message); this.data = data as ErrorData; data.err = data.error; } } function getKeyStatusError( keyStatus: MediaKeyStatus, decryptdata: LevelKey, ): EMEKeyError { const outputRestricted = keyStatus === 'output-restricted'; const details = outputRestricted ? ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED : ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR; return new EMEKeyError( { type: ErrorTypes.KEY_SYSTEM_ERROR, details, fatal: false, decryptdata, }, outputRestricted ? 'HDCP level output restricted' : `key status changed to "${keyStatus}"`, ); } export default EMEController;