src/controller/eme-controller.ts
- /**
- * @author Stephan Hesse <disparat@gmail.com> | <tchakabam@gmail.com>
- *
- * DRM support for Hls.js
- */
- import { Events } from '../events';
- import { ErrorTypes, ErrorDetails } from '../errors';
- import { logger } from '../utils/logger';
- import {
- getKeySystemsForConfig,
- getSupportedMediaKeySystemConfigurations,
- keySystemDomainToKeySystemFormat as keySystemToKeySystemFormat,
- KeySystemFormats,
- keySystemFormatToKeySystemDomain,
- KeySystemIds,
- keySystemIdToKeySystemDomain,
- } from '../utils/mediakeys-helper';
- import {
- KeySystems,
- requestMediaKeySystemAccess,
- } from '../utils/mediakeys-helper';
- import { strToUtf8array } from '../utils/keysystem-util';
- import { base64Decode } from '../utils/numeric-encoding-utils';
- import { DecryptData, LevelKey } from '../loader/level-key';
- import Hex from '../utils/hex';
- import { bin2str, parsePssh, parseSinf } from '../utils/mp4-tools';
- import EventEmitter from 'eventemitter3';
- import type Hls from '../hls';
- import type { ComponentAPI } from '../types/component-api';
- import type {
- MediaAttachedData,
- KeyLoadedData,
- ErrorData,
- ManifestLoadedData,
- } from '../types/events';
- import type { EMEControllerConfig } from '../config';
- import type { Fragment } from '../loader/fragment';
-
- const MAX_LICENSE_REQUEST_FAILURES = 3;
- const LOGGER_PREFIX = '[eme]';
-
- interface KeySystemAccessPromises {
- keySystemAccess: Promise<MediaKeySystemAccess>;
- mediaKeys?: Promise<MediaKeys>;
- certificate?: Promise<BufferSource | void>;
- }
-
- export interface MediaKeySessionContext {
- keySystem: KeySystems;
- mediaKeys: MediaKeys;
- decryptdata: LevelKey;
- mediaKeysSession: MediaKeySession;
- keyStatus: MediaKeyStatus;
- licenseXhr?: XMLHttpRequest;
- }
-
- /**
- * 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 implements ComponentAPI {
- public static CDMCleanupPromise: Promise<void> | void;
-
- private readonly hls: Hls;
- private readonly config: EMEControllerConfig;
- private media: HTMLMediaElement | null = null;
- private keyFormatPromise: Promise<KeySystemFormats> | null = null;
- private keySystemAccessPromises: {
- [keysystem: string]: KeySystemAccessPromises;
- } = {};
- private _requestLicenseFailureCount: number = 0;
- private mediaKeySessions: MediaKeySessionContext[] = [];
- private keyIdToKeySessionPromise: {
- [keyId: string]: Promise<MediaKeySessionContext>;
- } = {};
- private setMediaKeysQueue: Promise<void>[] = EMEController.CDMCleanupPromise
- ? [EMEController.CDMCleanupPromise]
- : [];
- private onMediaEncrypted = this._onMediaEncrypted.bind(this);
- private onWaitingForKey = this._onWaitingForKey.bind(this);
-
- private debug: (msg: any) => void = logger.debug.bind(logger, LOGGER_PREFIX);
- private log: (msg: any) => void = logger.log.bind(logger, LOGGER_PREFIX);
- private warn: (msg: any) => void = logger.warn.bind(logger, LOGGER_PREFIX);
- private error: (msg: any) => void = logger.error.bind(logger, LOGGER_PREFIX);
-
- constructor(hls: Hls) {
- this.hls = hls;
- this.config = hls.config;
- this.registerListeners();
- }
-
- public destroy() {
- this.unregisterListeners();
- this.onMediaDetached();
- // @ts-ignore
- this.hls =
- this.onMediaEncrypted =
- this.onWaitingForKey =
- this.keyIdToKeySessionPromise =
- null as any;
- }
-
- 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_LOADED, this.onManifestLoaded, 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_LOADED, this.onManifestLoaded, this);
- }
-
- private getLicenseServerUrl(keySystem: KeySystems): string | never {
- const { drmSystems, widevineLicenseUrl } = this.config;
- const keySystemConfiguration = drmSystems[keySystem];
-
- if (keySystemConfiguration) {
- return keySystemConfiguration.licenseUrl;
- }
-
- // For backward compatibility
- if (keySystem === KeySystems.WIDEVINE && widevineLicenseUrl) {
- return widevineLicenseUrl;
- }
-
- throw new Error(
- `no license server URL configured for key-system "${keySystem}"`
- );
- }
-
- 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<MediaKeySystemAccess> {
- 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<MediaKeys> {
- // This can throw, but is caught in event handler callpath
- const mediaKeySystemConfigs = getSupportedMediaKeySystemConfigurations(
- keySystem,
- audioCodecs,
- videoCodecs,
- this.config.drmSystemOptions
- );
- const keySystemAccessPromises: KeySystemAccessPromises =
- this.keySystemAccessPromises[keySystem];
- let keySystemAccess = keySystemAccessPromises?.keySystemAccess;
- if (!keySystemAccess) {
- this.log(
- `Requesting encrypted media "${keySystem}" key-system access with config: ${JSON.stringify(
- mediaKeySystemConfigs
- )}`
- );
- keySystemAccess = this.requestMediaKeySystemAccess(
- keySystem,
- mediaKeySystemConfigs
- );
- const keySystemAccessPromises: KeySystemAccessPromises =
- (this.keySystemAccessPromises[keySystem] = {
- keySystemAccess,
- });
- 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}"`);
- keySystemAccessPromises.mediaKeys = mediaKeySystemAccess
- .createMediaKeys()
- .then((mediaKeys) => {
- this.log(`Media-keys created for "${keySystem}"`);
- return certificateRequest.then((certificate) => {
- if (certificate) {
- return this.setMediaKeysServerCertificate(
- mediaKeys,
- keySystem,
- certificate
- );
- }
- return mediaKeys;
- });
- });
-
- keySystemAccessPromises.mediaKeys.catch((error) => {
- this.error(
- `Failed to create media-keys for "${keySystem}"}: ${error}`
- );
- });
-
- return keySystemAccessPromises.mediaKeys;
- });
- }
- return keySystemAccess.then(() => keySystemAccessPromises.mediaKeys!);
- }
-
- private createMediaKeySessionContext({
- decryptdata,
- keySystem,
- mediaKeys,
- }: {
- decryptdata: LevelKey;
- keySystem: KeySystems;
- mediaKeys: MediaKeys;
- }): MediaKeySessionContext {
- console.assert(!!mediaKeys, 'mediaKeys is defined');
-
- this.log(
- `Creating key-system session "${keySystem}" keyId: ${Hex.hexDump(
- decryptdata.keyId! || []
- )}`
- );
-
- 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 = this.getKeyIdString(decryptdata);
- const scheme = 'cenc';
- this.keyIdToKeySessionPromise[keyId] =
- this.generateRequestWithPreferredKeySession(
- keySessionContext,
- scheme,
- decryptdata.pssh,
- 'expired'
- );
- } else {
- this.warn(`Could not renew expired session. Missing pssh initData.`);
- }
- this.removeSession(mediaKeySessionContext);
- }
-
- private 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 Hex.hexDump(decryptdata.keyId);
- }
-
- private updateKeySession(
- mediaKeySessionContext: MediaKeySessionContext,
- data: Uint8Array
- ): Promise<void> {
- const keySession = mediaKeySessionContext.mediaKeysSession;
- this.log(
- `Updating key-session "${keySession.sessionId}" for keyID ${Hex.hexDump(
- mediaKeySessionContext.decryptdata?.keyId! || []
- )}
- } (data length: ${data ? data.byteLength : data})`
- );
- return keySession.update(data);
- }
-
- public selectKeySystemFormat(frag: Fragment): Promise<KeySystemFormats> {
- 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<KeySystemFormats> {
- return new Promise((resolve, reject) => {
- const keySystemsInConfig = getKeySystemsForConfig(this.config);
- const keySystemsToAttempt = keyFormats
- .map(keySystemFormatToKeySystemDomain)
- .filter(
- (value) => !!value && keySystemsInConfig.indexOf(value) !== -1
- ) as any as KeySystems[];
- return this.getKeySystemSelectionPromise(keySystemsToAttempt)
- .then(({ keySystem }) => {
- const keySystemFormat = keySystemToKeySystemFormat(keySystem);
- if (keySystemFormat) {
- resolve(keySystemFormat);
- } else {
- reject(
- new Error(`Unable to find format for key-system "${keySystem}"`)
- );
- }
- })
- .catch(reject);
- });
- }
-
- public loadKey(data: KeyLoadedData): Promise<MediaKeySessionContext> {
- const decryptdata = data.keyInfo.decryptdata;
-
- const keyId = this.getKeyIdString(decryptdata);
- const keyDetails = `(keyId: ${keyId} format: "${decryptdata.keyFormat}" method: ${decryptdata.method} uri: ${decryptdata.uri})`;
-
- this.log(`Starting session for key ${keyDetails}`);
-
- let keySessionContextPromise = this.keyIdToKeySessionPromise[keyId];
- if (!keySessionContextPromise) {
- keySessionContextPromise = this.keyIdToKeySessionPromise[keyId] =
- 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();
- const keySessionContext = this.createMediaKeySessionContext({
- keySystem,
- mediaKeys,
- decryptdata,
- });
- const scheme = 'cenc';
- return this.generateRequestWithPreferredKeySession(
- keySessionContext,
- scheme,
- decryptdata.pssh,
- 'playlist-key'
- );
- });
- }
- );
-
- keySessionContextPromise.catch((error) => this.handleError(error));
- }
-
- return keySessionContextPromise;
- }
-
- private throwIfDestroyed(message = 'Invalid state'): void | never {
- if (!this.hls) {
- throw new Error('invalid state');
- }
- }
-
- private handleError(error: EMEKeyError | Error) {
- if (!this.hls) {
- return;
- }
- this.error(error.message);
- if (error instanceof EMEKeyError) {
- this.hls.trigger(Events.ERROR, error.data);
- } else {
- 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 = this.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 ${JSON.stringify({
- drmSystems: this.config.drmSystems,
- })}`
- );
- }
- return this.attemptKeySystemAccess(keySystemsToAttempt);
- }
-
- private _onMediaEncrypted(event: MediaEncryptedEvent) {
- const { initDataType, initData } = event;
- this.debug(`"${event.type}" event: init data type: "${initDataType}"`);
-
- // Ignore event when initData is null
- if (initData === null) {
- return;
- }
-
- let keyId: Uint8Array | undefined;
- let keySystemDomain: KeySystems | undefined;
-
- if (
- initDataType === 'sinf' &&
- this.config.drmSystems[KeySystems.FAIRPLAY]
- ) {
- // Match sinf keyId to playlist skd://keyId=
- const json = bin2str(new Uint8Array(initData));
- try {
- const sinf = base64Decode(JSON.parse(json).sinf);
- const tenc = parseSinf(new Uint8Array(sinf));
- if (!tenc) {
- return;
- }
- keyId = tenc.subarray(8, 24);
- keySystemDomain = KeySystems.FAIRPLAY;
- } catch (error) {
- this.warn('Failed to parse sinf "encrypted" event message initData');
- return;
- }
- } else {
- // Support clear-lead key-session creation (otherwise depend on playlist keys)
- const psshInfo = parsePssh(initData);
- if (psshInfo === null) {
- return;
- }
- if (
- psshInfo.version === 0 &&
- psshInfo.systemId === KeySystemIds.WIDEVINE &&
- psshInfo.data
- ) {
- keyId = psshInfo.data.subarray(8, 24);
- }
- keySystemDomain = keySystemIdToKeySystemDomain(
- psshInfo.systemId as KeySystemIds
- );
- }
-
- if (!keySystemDomain || !keyId) {
- return;
- }
-
- const keyIdHex = Hex.hexDump(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.pssh || !decryptdata.keyId) {
- continue;
- }
- const oldKeyIdHex = Hex.hexDump(decryptdata.keyId);
- if (
- keyIdHex === oldKeyIdHex ||
- decryptdata.uri.replace(/-/g, '').indexOf(keyIdHex) !== -1
- ) {
- keySessionContextPromise = keyIdToKeySessionPromise[oldKeyIdHex];
- 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'
- );
- });
- break;
- }
- }
-
- if (!keySessionContextPromise) {
- // Clear-lead key (not encountered in playlist)
- keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex] =
- this.getKeySystemSelectionPromise([keySystemDomain]).then(
- ({ keySystem, mediaKeys }) => {
- this.throwIfDestroyed();
- const decryptdata = new LevelKey(
- 'ISO-23001-7',
- keyIdHex,
- keySystemToKeySystemFormat(keySystem) ?? ''
- );
- decryptdata.pssh = new Uint8Array(initData);
- decryptdata.keyId = keyId as Uint8Array;
- return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => {
- this.throwIfDestroyed();
- const keySessionContext = this.createMediaKeySessionContext({
- decryptdata,
- keySystem,
- mediaKeys,
- });
- return this.generateRequestWithPreferredKeySession(
- keySessionContext,
- initDataType,
- initData,
- 'encrypted-event-no-match'
- );
- });
- }
- );
- }
- keySessionContextPromise.catch((error) => this.handleError(error));
- }
-
- private _onWaitingForKey(event: Event) {
- this.log(`"${event.type}" event`);
- }
-
- private attemptSetMediaKeys(
- keySystem: KeySystems,
- mediaKeys: MediaKeys
- ): Promise<void> {
- 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) {
- throw new Error(
- 'Attempted to set mediaKeys without media element attached'
- );
- }
- return this.media.setMediaKeys(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<MediaKeySessionContext> | never {
- const generateRequestFilter =
- this.config.drmSystems?.[context.keySystem]?.generateRequest;
- if (generateRequestFilter) {
- try {
- const mappedInitData: ReturnType<typeof generateRequestFilter> =
- generateRequestFilter.call(this.hls, initDataType, initData, context);
- if (!mappedInitData) {
- throw new Error(
- 'Invalid response from configured generateRequest filter'
- );
- }
- initDataType = mappedInitData.initDataType;
- initData = context.decryptdata.pssh = mappedInitData.initData
- ? new Uint8Array(mappedInitData.initData)
- : null;
- } catch (error) {
- this.warn(error.message);
- if (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 = this.getKeyIdString(context.decryptdata);
- this.log(
- `Generating key-session request for "${reason}": ${keyId} (init data type: ${initDataType} length: ${
- initData ? initData.byteLength : null
- })`
- );
-
- const licenseStatus = new EventEmitter();
-
- context.mediaKeysSession.onmessage = (event: MediaKeyMessageEvent) => {
- const keySession = context.mediaKeysSession;
- if (!keySession) {
- 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) => {
- this.handleError(error);
- licenseStatus.emit('error', error);
- });
- } else if (messageType === 'license-release') {
- if (context.keySystem === KeySystems.FAIRPLAY) {
- this.updateKeySession(context, strToUtf8array('acknowledged'));
- this.removeSession(context);
- }
- } else {
- this.warn(`unhandled media key message type "${messageType}"`);
- }
- };
-
- context.mediaKeysSession.onkeystatuseschange = (
- event: MediaKeyMessageEvent
- ) => {
- const keySession = context.mediaKeysSession;
- if (!keySession) {
- licenseStatus.emit('error', new Error('invalid state'));
- return;
- }
- this.onKeyStatusChange(context);
- const keyStatus = context.keyStatus;
- licenseStatus.emit('keyStatus', keyStatus);
- if (keyStatus === 'expired') {
- this.warn(`${context.keySystem} expired for key ${keyId}`);
- this.renewKeySession(context);
- }
- };
-
- const keyUsablePromise = new Promise(
- (resolve: (value?: void) => void, reject) => {
- licenseStatus.on('error', reject);
-
- licenseStatus.on('keyStatus', (keyStatus) => {
- if (keyStatus.startsWith('usable')) {
- resolve();
- } else if (keyStatus === 'output-restricted') {
- reject(
- new EMEKeyError(
- {
- type: ErrorTypes.KEY_SYSTEM_ERROR,
- details: ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED,
- fatal: false,
- },
- 'HDCP level output restricted'
- )
- );
- } else if (keyStatus === 'internal-error') {
- reject(
- new EMEKeyError(
- {
- type: ErrorTypes.KEY_SYSTEM_ERROR,
- details: ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR,
- fatal: true,
- },
- `key status changed to "${keyStatus}"`
- )
- );
- } else if (keyStatus === 'expired') {
- reject(new Error('key expired while generating request'));
- } else {
- this.warn(`unhandled key status change "${keyStatus}"`);
- }
- });
- }
- );
-
- return context.mediaKeysSession
- .generateRequest(initDataType, initData)
- .then(() => {
- this.log(
- `Request generated for key-session "${context.mediaKeysSession?.sessionId}" keyId: ${keyId}`
- );
- })
- .catch((error) => {
- throw new EMEKeyError(
- {
- type: ErrorTypes.KEY_SYSTEM_ERROR,
- details: ErrorDetails.KEY_SYSTEM_NO_SESSION,
- error,
- fatal: false,
- },
- `Error generating key-session request: ${error}`
- );
- })
- .then(() => keyUsablePromise)
- .catch((error) => {
- licenseStatus.removeAllListeners();
- this.removeSession(context);
- throw error;
- })
- .then(() => {
- licenseStatus.removeAllListeners();
- return context;
- });
- }
-
- private onKeyStatusChange(mediaKeySessionContext: MediaKeySessionContext) {
- mediaKeySessionContext.mediaKeysSession.keyStatuses.forEach(
- (status: MediaKeyStatus, keyId: BufferSource) => {
- this.log(
- `key status change "${status}" for keyStatuses keyId: ${Hex.hexDump(
- 'buffer' in keyId
- ? new Uint8Array(keyId.buffer, keyId.byteOffset, keyId.byteLength)
- : new Uint8Array(keyId)
- )} session keyId: ${Hex.hexDump(
- new Uint8Array(mediaKeySessionContext.decryptdata.keyId || [])
- )} uri: ${mediaKeySessionContext.decryptdata.uri}`
- );
- mediaKeySessionContext.keyStatus = status;
- }
- );
- }
-
- private fetchServerCertificate(
- keySystem: KeySystems
- ): Promise<BufferSource | void> {
- return new Promise((resolve, reject) => {
- const url = this.getServerCertificateUrl(keySystem);
- if (!url) {
- return resolve();
- }
- this.log(`Fetching serverCertificate for "${keySystem}"`);
- const xhr = new XMLHttpRequest();
- xhr.open('GET', url, true);
- xhr.responseType = 'arraybuffer';
- xhr.onreadystatechange = () => {
- if (xhr.readyState === XMLHttpRequest.DONE) {
- if (xhr.status === 200) {
- resolve(xhr.response);
- } else {
- reject(
- new EMEKeyError(
- {
- type: ErrorTypes.KEY_SYSTEM_ERROR,
- details:
- ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED,
- fatal: true,
- networkDetails: xhr,
- },
- `"${keySystem}" certificate request XHR failed (${url}). Status: ${xhr.status} (${xhr.statusText})`
- )
- );
- }
- }
- };
- xhr.send();
- });
- }
-
- private setMediaKeysServerCertificate(
- mediaKeys: MediaKeys,
- keySystem: KeySystems,
- cert: BufferSource
- ): Promise<MediaKeys> {
- 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<void> {
- 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,
- error,
- fatal: true,
- },
- error.message
- );
- }
- );
- }
- );
- }
-
- 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) {
- throw new Error('Key removed');
- }
- return licenseXhrSetup.call(
- this.hls,
- xhr,
- url,
- keysListItem,
- licenseChallenge
- );
- })
- .catch((error: Error) => {
- if (!keysListItem.decryptdata) {
- // 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<ArrayBuffer> {
- return new Promise((resolve, reject) => {
- const url = this.getLicenseServerUrl(keySessionContext.keySystem);
- this.log(`Sending license request to URL: ${url}`);
- const xhr = new XMLHttpRequest();
- xhr.responseType = 'arraybuffer';
- xhr.onreadystatechange = () => {
- if (!this.hls || !keySessionContext.mediaKeysSession) {
- 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 {
- this._requestLicenseFailureCount++;
- if (
- this._requestLicenseFailureCount > MAX_LICENSE_REQUEST_FAILURES ||
- (xhr.status >= 400 && xhr.status < 500)
- ) {
- reject(
- new EMEKeyError(
- {
- type: ErrorTypes.KEY_SYSTEM_ERROR,
- details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED,
- fatal: true,
- networkDetails: xhr,
- },
- `License Request XHR failed (${url}). Status: ${xhr.status} (${xhr.statusText})`
- )
- );
- } else {
- const attemptsLeft =
- MAX_LICENSE_REQUEST_FAILURES -
- 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 }) => {
- xhr.send(licenseChallenge);
- }
- );
- });
- }
-
- private onMediaAttached(
- event: Events.MEDIA_ATTACHED,
- data: MediaAttachedData
- ) {
- if (!this.config.emeEnabled) {
- return;
- }
-
- const media = data.media;
-
- // keep reference of media
- this.media = media;
-
- media.addEventListener('encrypted', this.onMediaEncrypted);
- media.addEventListener('waitingforkey', this.onWaitingForKey);
- }
-
- private onMediaDetached() {
- const media = this.media;
- const mediaKeysList = this.mediaKeySessions;
- if (media) {
- media.removeEventListener('encrypted', this.onMediaEncrypted);
- media.removeEventListener('waitingforkey', this.onWaitingForKey);
- this.media = null;
- }
-
- this._requestLicenseFailureCount = 0;
- this.setMediaKeysQueue = [];
- this.mediaKeySessions = [];
- this.keyIdToKeySessionPromise = {};
- 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).catch((error) => {
- this.log(
- `Could not clear media keys: ${error}. media.src: ${media?.src}`
- );
- })
- )
- )
- .then(() => {
- if (keySessionCount) {
- this.log('finished closing key sessions and clearing media keys');
- mediaKeysList.length = 0;
- }
- })
- .catch((error) => {
- this.log(
- `Could not close sessions and clear media keys: ${error}. media.src: ${media?.src}`
- );
- });
- }
-
- 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<void> | void {
- const { mediaKeysSession, licenseXhr } = mediaKeySessionContext;
- if (mediaKeysSession) {
- this.log(
- `Remove licenses and keys and close session ${mediaKeysSession.sessionId}`
- );
- mediaKeysSession.onmessage = null;
- mediaKeysSession.onkeystatuseschange = null;
- 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);
- }
- return mediaKeysSession
- .remove()
- .catch((error) => {
- this.log(`Could not remove session: ${error}`);
- })
- .then(() => {
- return mediaKeysSession.close();
- })
- .catch((error) => {
- this.log(`Could not close session: ${error}`);
- });
- }
- }
- }
-
- class EMEKeyError extends Error {
- public readonly data: ErrorData;
- constructor(data: ErrorData, message: string) {
- super(message);
- this.data = data;
- data.err = data.error;
- }
- }
-
- export default EMEController;