From 5829342dda529cb80c7a939a34950420c9a57863 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Fri, 7 Jan 2022 18:41:23 +0100 Subject: [PATCH] DRM: re-implement `singleLicensePer` handling This update is build on the DRM refacto started in #1042 and fixes some of (complex, yet relatively minor) performance issues that could be encountered in a `singleLicensePer: "content"` mode. It also paves the way for the future `singleLicensePer: "periods"` mode. In particular, it does the following things: Optimization on the in-memory MediaKeySession cache when in `singleLicensePer: "content"` mode ----------------------------------------------------------- The in-memory cache of MediaKeySessions presented by the `LoadedSessionsStore` - which basically allows faster loading of already-loaded content by re-using a recently-created MediaKeySession before relied only on the initialization data for identification purposes. This means that false negatives can occur (in that: cache miss where it should have been a hit) in the `singleLicensePer: "content"` mode (which implies that a single license request will actually retrieve all keys linked to the content), if when re-loading the content a different initialization data is initially encountered. This would be a false negative here because even if this is different initial initialization data than before, the already-created MediaKeySession still should have received the key to decrypt that other quality. This is now fixed by linking a MediaKeySession in that cache both to the initialization data linked to it and to all key ids that are explicitely (i.e. in the fetched license) AND implicitely (i.e. in the Manifest file when in a `singleLicensePer: "content"` mode yet not in the license) linked to it. This is done through the declaration of a new structure, the `KeySessionRecord`, which can be "associated" to supplementary key ids at any time, and which contains an `isCompatibleWith` method to quickly check if newly encountered initialization data and/or key id is actually compatible to an already-created MediaKeySession. MediaKeySessions loaded (persisted ones) AND retrieved from our memory cache now are not subject to the `singleLicensePer` option. ---------------------------------------------------------------------- All persisted MediaKeySessions as well as all those kept in our in-memory cache were before subject to the same `singleLicensePer` rules than the current content that is being loaded. This means that technically, a license fetched for a previous content in a `singleLicensePer: "init-data"` mode (the default), could be wrongly inferred to be a `singleLicensePer: "content"` one if a new content compatible with it was loaded in that mode. This would result in the impossibility to use more than the one key stored with that MediaKeySession. This issue is described by the problem 1 of the #1031 issue (note that problem 2 is not yet fixed, though it is a work-in-progress). To fix this, the RxPlayer does now two things: 1. A cached or persisted (still in WIP for that second one) MediaKeySession will now be linked in the corresponding cache to all known key id that are linked to it either implicit or explicit (description of both term higher in this message). 2. Only MediaKeySessions receiving fetched license will now follow the `singleLicensePer` rule, all other type of sessions (loaded or retrieved from cache) will only be linked to the key id known at the time the caching has been done. This could mean relatively rare false negatives when a re-loaded content in a `singleLicensePer: "content"` mode contains key ids previously unheard of, but it is still the safest solution. Write skeleton for the future `singleLicensePer: "periods"` ----------------------------------------------------------- One of the goal of that big implementation was also to pave the way for more complex `singleLicensePer` modes, as we plan to do for the v3.27.0 (#1028). The main differences are that the `ContentDecryptor` (the new `EMEManager`) now also receives Manifest-related structures in the initialization data emitted to it and perform all the blacklisting by itself. This allows it to e.g. know about implicit key ids (keys that should have been in the license according to the `singleLicensePer` mode but which aren't) without necessitating a round-trip with the Init module. Remaining issues ---------------- One unfortunate side-effect of the current implementation, is the creation of a lock to only allow one initialization data at a time until either the right MediaKeySession is obtained (either created, loaded, or retrieved from cache) in `singleLicensePer: "init-data"` mode or until the license's keys are available in singleLicensePer: "content"` mode. This could mean unnecessary waiting when a persistent MediaKeySession is being loaded, and even more when we have troubles loading it - which is a frequent occurence on some set-top-box. I'm sure that we could write a less strict lock though, I just didn't take the time to do it. Another remaining issue is that I did not finish working on persisted MediaKeySession here, most notably to fix the problem 2 exposed by the #1031 issue. --- src/core/decrypt/README.md | 2 +- src/core/decrypt/content_decryptor.ts | 745 ++++++++++++------ src/core/decrypt/create_or_load_session.ts | 106 ++- src/core/decrypt/create_session.ts | 114 +-- src/core/decrypt/session_events_listener.ts | 65 +- src/core/decrypt/types.ts | 300 +++---- .../clean_old_loaded_sessions.test.ts | 38 +- .../utils/clean_old_loaded_sessions.ts | 22 +- src/core/decrypt/utils/is_session_usable.ts | 1 - src/core/decrypt/utils/key_id_comparison.ts | 58 ++ src/core/decrypt/utils/key_session_record.ts | 207 +++++ .../decrypt/utils/loaded_sessions_store.ts | 202 +++-- .../utils/persistent_sessions_store.ts | 32 +- src/core/init/initialize_media_source.ts | 13 - src/core/init/link_drm_and_content.ts | 24 - src/core/stream/events_generators.ts | 13 +- .../representation/representation_stream.ts | 6 +- src/manifest/manifest.ts | 71 +- src/manifest/representation.ts | 39 +- 19 files changed, 1249 insertions(+), 809 deletions(-) create mode 100644 src/core/decrypt/utils/key_id_comparison.ts create mode 100644 src/core/decrypt/utils/key_session_record.ts diff --git a/src/core/decrypt/README.md b/src/core/decrypt/README.md index bde0e2a077..4a7dc35957 100644 --- a/src/core/decrypt/README.md +++ b/src/core/decrypt/README.md @@ -4,7 +4,7 @@ ## Overview #################################################################### This directory exports the `ContentDecryptor`, which allows to easily interface -with the browser APIs for decrypting a crypted content, it follows the +with the browser APIs for decrypting an encrypted content, it follows the [Encrypted Media Extensions recommandations](https://www.w3.org/TR/encrypted-media/). The `ContentDecryptor` is a module isolated from the rest of the player taking diff --git a/src/core/decrypt/content_decryptor.ts b/src/core/decrypt/content_decryptor.ts index aa84bbb704..f37131ff24 100644 --- a/src/core/decrypt/content_decryptor.ts +++ b/src/core/decrypt/content_decryptor.ts @@ -19,6 +19,7 @@ import { events, generateKeyRequest, getInitData, + ICustomMediaKeys, ICustomMediaKeySystemAccess, } from "../../compat/"; import config from "../../config"; @@ -28,14 +29,14 @@ import { OtherError, } from "../../errors"; import log from "../../log"; +import Manifest from "../../manifest"; import areArraysOfNumbersEqual from "../../utils/are_arrays_of_numbers_equal"; +import arrayFind from "../../utils/array_find"; import arrayIncludes from "../../utils/array_includes"; import { concat } from "../../utils/byte_parsing"; import EventEmitter from "../../utils/event_emitter"; import isNullOrUndefined from "../../utils/is_null_or_undefined"; -import createSharedReference, { - ISharedReference, -} from "../../utils/reference"; +import { bytesToHex } from "../../utils/string_parsing"; import TaskCanceller from "../../utils/task_canceller"; import attachMediaKeys from "./attach_media_keys"; import createOrLoadSession from "./create_or_load_session"; @@ -46,15 +47,18 @@ import SessionEventsListener, { } from "./session_events_listener"; import setServerCertificate from "./set_server_certificate"; import { - IAttachedMediaKeysData, IInitializationDataInfo, IKeySystemOption, - IKeyUpdateValue, + IMediaKeySessionStores, + MediaKeySessionLoadingType, } from "./types"; -import { ICleaningOldSessionDataPayload } from "./utils/clean_old_loaded_sessions"; import cleanOldStoredPersistentInfo from "./utils/clean_old_stored_persistent_info"; import getDrmSystemId from "./utils/get_drm_system_id"; -import InitDataStore from "./utils/init_data_store"; +import { + areAllKeyIdContainedIn, + areSomeKeyIdContainedIn, +} from "./utils/key_id_comparison"; +import KeySessionRecord from "./utils/key_session_record"; const { EME_DEFAULT_MAX_SIMULTANEOUS_MEDIA_KEY_SESSIONS, EME_MAX_STORED_PERSISTENT_SESSION_INFORMATION } = config; @@ -106,20 +110,12 @@ export default class ContentDecryptor extends EventEmitter; - - /** - * Keep track of which initialization data have been blacklisted in the - * current instance of the `ContentDecryptor`. - * If the same initialization data is encountered again, we can directly emit - * the same `BlacklistedSessionError`. + * Contains information about all key sessions loaded for this current + * content. + * This object is most notably used to check which keys are already obtained, + * thus avoiding to perform new unnecessary license requests and CDM interactions. */ - private _blacklistedInitData : InitDataStore; + private _currentSessions : IActiveSessionInfo[]; /** * Allows to dispose the resources taken by the current instance of the @@ -133,6 +129,16 @@ export default class ContentDecryptor extends EventEmitter(); - this._blacklistedInitData = new InitDataStore(); + this._currentSessions = []; this._canceller = canceller; this._wasAttachCalled = false; + this._initDataQueue = []; this._stateData = { state: ContentDecryptorState.Initializing, - data: { initDataQueue: [] } }; + isMediaKeysAttached: false, + isInitDataQueueLocked: true, + data: null }; this.error = null; const listenerSub = onEncrypted$(mediaElement).subscribe(evt => { @@ -193,10 +201,10 @@ export default class ContentDecryptor extends EventEmitter { this._onFatalError(err); }); } @@ -351,85 +349,54 @@ export default class ContentDecryptor extends EventEmitter} */ private async _processInitializationData( - initializationData: IInitializationDataInfo + initializationData: IInitializationDataInfo, + mediaKeysData: IAttachedMediaKeysData ) : Promise { - if (this._stateData.state !== ContentDecryptorState.ReadyForContent) { - if (this._stateData.state === ContentDecryptorState.Disposed || - this._stateData.state === ContentDecryptorState.Error) - { - throw new Error("ContentDecryptor either disposed or stopped."); - } - this._stateData.data.initDataQueue.push(initializationData); - return ; - } else if (!this._stateData.data.isAttached) { - this._stateData.data.initDataQueue.push(initializationData); - return ; - } - - const mediaKeysData = this._stateData.data.mediaKeysData; - const contentSessions = this._contentSessions; const { mediaKeySystemAccess, stores, options } = mediaKeysData; - const blacklistError = this._blacklistedInitData.get(initializationData); - - if (blacklistError !== undefined) { - if (initializationData.type === undefined) { - log.error("DRM: The current session has already been blacklisted " + - "but the current content is not known. Throwing."); - const { sessionError } = blacklistError; - sessionError.fatal = true; - throw sessionError; - } - log.warn("DRM: The current session has already been blacklisted. " + - "Blacklisting content."); - this.trigger("blacklistProtectionData", initializationData); - return ; - } - const lastKeyUpdate = createSharedReference(null); - - // First, check that this initialization data is not already handled - if (options.singleLicensePer === "content" && !contentSessions.isEmpty()) { - const keyIds = initializationData.keyIds; - if (keyIds === undefined) { - log.warn("DRM: Initialization data linked to unknown key id, we'll " + - "not able to fallback from it."); - return ; - } + if (this._tryToUseAlreadyCreatedSession(initializationData, mediaKeysData) || + this._isStopped()) // _isStopped is voluntarly checked after here + { + return; + } - const firstSession = contentSessions.getAll()[0]; - firstSession.lastKeyUpdate.onUpdate((val) => { - if (val === null) { - return; - } - const hasAllNeededKeyIds = keyIds.every(keyId => { - for (let i = 0; i < val.whitelistedKeyIds.length; i++) { - if (areArraysOfNumbersEqual(val.whitelistedKeyIds[i], keyId)) { - return true; - } + if (options.singleLicensePer === "content") { + const firstCreatedSession = arrayFind(this._currentSessions, (x) => + x.source === MediaKeySessionLoadingType.Created); + + if (firstCreatedSession !== undefined) { + // We already fetched a `singleLicensePer: "content"` license, yet we + // could not use the already-created MediaKeySession with it. + // It means that we'll never handle it and we should thus blacklist it. + const keyIds = initializationData.keyIds; + if (keyIds === undefined) { + if (initializationData.content === undefined) { + log.warn("DRM: Unable to fallback from a non-decipherable quality."); + } else { + blackListProtectionData(initializationData.content.manifest, + initializationData); } - }); - - if (!hasAllNeededKeyIds) { - // Not all keys are available in the current session, blacklist those - this.trigger("keyUpdate", { blacklistedKeyIDs: keyIds, - whitelistedKeyIds: [] }); - return; + return ; } - // Already handled by the current session. - // Move corresponding session on top of the cache if it exists - const { loadedSessionsStore } = mediaKeysData.stores; - loadedSessionsStore.reuse(firstSession.initializationData); + firstCreatedSession.record.associateKeyIds(keyIds); + if (initializationData.content !== undefined) { + if (log.getLevel() === "DEBUG") { + const hexKids = keyIds.reduce((acc, kid) => `${acc}, ${bytesToHex(kid)}`, ""); + log.debug("DRM: Blacklisting new key ids", hexKids); + } + updateDecipherability(initializationData.content.manifest, [], keyIds); + } return ; - }, { clearSignal: this._canceller.signal, emitCurrentValue: true }); - - return ; - } else if (!contentSessions.storeIfNone(initializationData, { initializationData, - lastKeyUpdate })) { - log.debug("DRM: Init data already received. Skipping it."); - return ; + } } + // /!\ Do not forget to unlock when done + // TODO this is error-prone and can lead to performance issue when loading + // persistent sessions. + // Can we find a better strategy? + this._lockInitDataQueue(); + let wantedSessionType : MediaKeySessionType; if (options.persistentLicense !== true) { wantedSessionType = "temporary"; @@ -448,8 +415,19 @@ export default class ContentDecryptor extends EventEmitter { switch (evt.type) { case "warning": @@ -478,13 +457,14 @@ export default class ContentDecryptor extends EventEmitter { @@ -500,59 +480,84 @@ export default class ContentDecryptor extends EventEmitter { if (!(err instanceof BlacklistedSessionError)) { this._onFatalError(err); return ; } - this._blacklistedInitData.store(initializationData, err); + sessionInfo.blacklistedSessionError = err; - const { sessionError } = err; - if (initializationData.type === undefined) { - log.error("DRM: Current session blacklisted and content not known. " + - "Throwing."); - sessionError.fatal = true; - throw sessionError; + if (initializationData.content !== undefined) { + const { manifest } = initializationData.content; + log.info("DRM: blacklisting Representations based on " + + "protection data."); + blackListProtectionData(manifest, initializationData); } - log.warn("DRM: Current session blacklisted. Blacklisting content."); - this.trigger("warning", sessionError); + this._unlockInitDataQueue(); - // The previous trigger might have lead to a disposal of the `ContentDecryptor`. - if (this._stateData.state !== ContentDecryptorState.Error && - this._stateData.state !== ContentDecryptorState.Disposed) - { - this.trigger("blacklistProtectionData", initializationData); - } + // TODO No warning yet for blacklisted session? + // this.trigger("warning", err); }, }); this._canceller.signal.register(() => { sub.unsubscribe(); }); - if (sessionRes.type === "created-session") { + if (options.singleLicensePer === undefined || + options.singleLicensePer === "init-data") + { + this._unlockInitDataQueue(); + } + + if (sessionRes.type === MediaKeySessionLoadingType.Created) { // `generateKeyRequest` awaits a single Uint8Array containing all // initialization data. const concatInitData = concat(...initializationData.values.map(i => i.data)); @@ -568,10 +573,112 @@ export default class ContentDecryptor extends EventEmitter x.record.isCompatibleWith(initializationData)); + + if (compatibleSessionInfo === undefined) { + return false; + } - function onCleaningSession(evt : ICleaningOldSessionDataPayload) { - contentSessions.remove(evt.initializationData); + // Check if the compatible session is blacklisted + const blacklistedSessionError = compatibleSessionInfo.blacklistedSessionError; + if (!isNullOrUndefined(blacklistedSessionError)) { + if (initializationData.type === undefined || + initializationData.content === undefined) + { + log.error("DRM: This initialization data has already been blacklisted " + + "but the current content is not known."); + return true; + } else { + log.info("DRM: This initialization data has already been blacklisted. " + + "Blacklisting the related content."); + const { manifest } = initializationData.content; + blackListProtectionData(manifest, initializationData); + return true; + } } + + // Check if the current key id(s) has been blacklisted by this session + if (compatibleSessionInfo.keyStatuses !== undefined && + initializationData.keyIds !== undefined) + { + /** + * If set to `true`, the Representation(s) linked to this + * initialization data's key id should be marked as "not decipherable". + */ + let isUndecipherable : boolean; + + if (options.singleLicensePer === "init-data") { + // Note: In the default "init-data" mode, we only avoid a + // Representation if the key id was originally explicitely + // blacklisted (and not e.g. if its key was just not present in + // the license). + // + // This is to enforce v3.x.x retro-compatibility: we cannot + // fallback from a Representation unless some RxPlayer option + // documentating this behavior has been set. + const { blacklisted } = compatibleSessionInfo.keyStatuses; + isUndecipherable = areSomeKeyIdContainedIn(initializationData.keyIds, + blacklisted); + } else { + // In any other mode, as soon as not all of this initialization + // data's linked key ids are explicitely whitelisted, we can mark + // the corresponding Representation as "not decipherable". + // This is because we've no such retro-compatibility guarantee to + // make there. + const { whitelisted } = compatibleSessionInfo.keyStatuses; + isUndecipherable = !areAllKeyIdContainedIn(initializationData.keyIds, + whitelisted); + } + + if (isUndecipherable) { + if (initializationData.content === undefined) { + log.error("DRM: Cannot forbid key id, the content is unknown."); + return true; + } + log.info("DRM: Current initialization data is linked to blacklisted keys. " + + "Marking Representations as not decipherable"); + updateDecipherability(initializationData.content.manifest, + [], + initializationData.keyIds); + return true; + } + } + + // If we reached here, it means that this initialization data is not + // blacklisted in any way. + // Search loaded session and put it on top of the cache if it exists. + const entry = stores.loadedSessionsStore.reuse(initializationData); + if (entry !== null) { + log.debug("DRM: Init data already processed. Skipping it."); + return true; + } + + // Session not found in `loadedSessionsStore`, it might have been closed + // since. + // Remove from `this._currentSessions` and start again. + const indexOf = this._currentSessions.indexOf(compatibleSessionInfo); + if (indexOf === -1) { + log.error("DRM: Unable to remove processed init data: not found."); + } else { + log.debug("DRM: A session from a processed init data is not available " + + "anymore. Re-processing it."); + this._currentSessions.splice(indexOf, 1); + } + return false; } private _onFatalError(err : unknown) { @@ -582,7 +689,11 @@ export default class ContentDecryptor extends EventEmitter { + if (representation.contentProtections === undefined) { + return representation.decipherable; + } + const contentKIDs = representation.contentProtections.keyIds; + for (let i = 0; i < contentKIDs.length; i++) { + const elt = contentKIDs[i]; + keyIds.push(elt.keyId); + for (let j = 0; j < whitelistedKeyIds.length; j++) { + if (areArraysOfNumbersEqual(whitelistedKeyIds[j], elt.keyId)) { + return true; + } + } + } + return false; + }); + return keyIds; +} + +function updateDecipherability( + manifest : Manifest, + whitelistedKeyIds : Uint8Array[], + blacklistedKeyIDs : Uint8Array[] +) : void { + manifest.updateRepresentationsDeciperability((representation) => { + if (representation.contentProtections === undefined) { + return representation.decipherable; + } + const contentKIDs = representation.contentProtections.keyIds; + for (let i = 0; i < contentKIDs.length; i++) { + const elt = contentKIDs[i]; + for (let j = 0; j < blacklistedKeyIDs.length; j++) { + if (areArraysOfNumbersEqual(blacklistedKeyIDs[j], elt.keyId)) { + return false; + } + } + for (let j = 0; j < whitelistedKeyIds.length; j++) { + if (areArraysOfNumbersEqual(whitelistedKeyIds[j], elt.keyId)) { + return true; + } + } + } + return representation.decipherable; + }); +} + +function blackListProtectionData( + manifest : Manifest, + initData : IInitializationDataInfo +) : void { + manifest.updateRepresentationsDeciperability((representation) => { + if (representation.decipherable === false) { + return false; + } + const segmentProtections = representation.contentProtections?.initData ?? []; + for (let i = 0; i < segmentProtections.length; i++) { + if (initData.type === undefined || + segmentProtections[i].type === initData.type) + { + const containedInitData = initData.values.every(undecipherableVal => { + return segmentProtections[i].values.some(currVal => { + return (undecipherableVal.systemId === undefined || + currVal.systemId === undecipherableVal.systemId) && + areArraysOfNumbersEqual(currVal.data, + undecipherableVal.data); + }); + }); + if (containedInitData) { + return false; + } + } + } + return representation.decipherable; + }); +} + /** Events sent by the `ContentDecryptor`, in a `{ event: payload }` format. */ export interface IContentDecryptorEvent { /** @@ -633,21 +850,6 @@ export interface IContentDecryptorEvent { */ warning : ICustomError; - /** - * Event emitted when we have an update on whitelisted keys (which can be - * used) and blacklisted keys (which cannot be used right now). - */ - keyUpdate : IKeyUpdateValue; - - /** - * Event Emitted when specific "protection data" cannot be deciphered and is - * thus blacklisted. - * - * The linked value is the initialization data linked to the content that - * cannot be deciphered. - */ - blacklistProtectionData: IInitializationDataInfo; - /** * Event emitted when the `ContentDecryptor`'s state changed. * States are a central aspect of the `ContentDecryptor`, be sure to check the @@ -708,65 +910,72 @@ type IContentDecryptorStateData = IInitializingStateData | IDisposeStateData | IErrorStateData; -/** ContentDecryptor's internal data when in the `Initializing` state. */ -interface IInitializingStateData { - state: ContentDecryptorState.Initializing; - data: { - /** - * This queue stores initialization data communicated while initializing so - * it can be processed when the initialization is done. - * This same queue is used while in the `Initializing` state, the - * `WaitingForAttachment` state and the `ReadyForContent` until the - * `MediaKeys` instance is actually attached to the HTMLMediaElement. - */ - initDataQueue : IInitializationDataInfo[]; - }; +/** Skeleton that all variants of `IContentDecryptorStateData` use. */ +interface IContentDecryptorStateBase< + TStateName extends ContentDecryptorState, + TIsQueueLocked extends boolean | undefined, + TIsMediaKeyAttached extends boolean | undefined, + TData +> { + /** Identify the ContentDecryptor's state. */ + state: TStateName; + /** + * If `true`, the `ContentDecryptor` will wait before processing + * newly-received initialization data. + * If `false`, it will process them right away. + * Set to undefined when it won't ever process them like for example in a + * disposed or errored state. + */ + isInitDataQueueLocked: TIsQueueLocked; + /** + * If `true`, the `MediaKeys` instance has been attached to the HTMLMediaElement. + * If `false`, it hasn't happened yet. + * If uncertain or unimportant (for example if the `ContentDecryptor` is an + * disposed/errored state, set to `undefined`). + */ + isMediaKeysAttached: TIsMediaKeyAttached; + /** Data stored relative to that state. */ + data: TData; } +/** ContentDecryptor's internal data when in the `Initializing` state. */ +type IInitializingStateData = IContentDecryptorStateBase< + ContentDecryptorState.Initializing, + true, // isInitDataQueueLocked + false, // isMediaKeysAttached + null // data +>; + /** ContentDecryptor's internal data when in the `WaitingForAttachment` state. */ -interface IWaitingForAttachmentStateData { - state: ContentDecryptorState.WaitingForAttachment; - data: { - /** - * This queue stores initialization data communicated while initializing so - * it can be processed when the initialization is done. - * This same queue is used while in the `Initializing` state, the - * `WaitingForAttachment` state and the `ReadyForContent` until the - * `MediaKeys` instance is actually attached to the HTMLMediaElement. - */ - initDataQueue : IInitializationDataInfo[]; - mediaKeysInfo : IMediaKeysInfos; - mediaElement : HTMLMediaElement; - }; -} +type IWaitingForAttachmentStateData = IContentDecryptorStateBase< + ContentDecryptorState.WaitingForAttachment, + true, // isInitDataQueueLocked + false, // isMediaKeysAttached + // data + { mediaKeysInfo : IMediaKeysInfos; + mediaElement : HTMLMediaElement; } +>; /** * ContentDecryptor's internal data when in the `ReadyForContent` state before * it has attached the `MediaKeys` to the media element. */ -interface IReadyForContentStateDataUnattached { - state: ContentDecryptorState.ReadyForContent; - data: { - isAttached: false; - /** - * This queue stores initialization data communicated while initializing so - * it can be processed when the initialization is done. - * This same queue is used while in the `Initializing` state, the - * `WaitingForAttachment` state and the `ReadyForContent` until the - * `MediaKeys` instance is actually attached to the HTMLMediaElement. - */ - initDataQueue : IInitializationDataInfo[]; - }; -} +type IReadyForContentStateDataUnattached = IContentDecryptorStateBase< + ContentDecryptorState.ReadyForContent, + true, // isInitDataQueueLocked + false, // isMediaKeysAttached + null // data +>; /** * ContentDecryptor's internal data when in the `ReadyForContent` state once * it has attached the `MediaKeys` to the media element. */ -interface IReadyForContentStateDataAttached { - state: ContentDecryptorState.ReadyForContent; - data: { - isAttached: true; +type IReadyForContentStateDataAttached = IContentDecryptorStateBase< + ContentDecryptorState.ReadyForContent, + boolean, // isInitDataQueueLocked + true, // isMediaKeysAttached + { /** * MediaKeys-related information linked to this instance of the * `ContentDecryptor`. @@ -775,24 +984,72 @@ interface IReadyForContentStateDataAttached { * Initialized state (@see ContentDecryptorState). */ mediaKeysData : IAttachedMediaKeysData; - }; -} + } +>; -/** ContentDecryptor's internal data when in the `ReadyForContent` state. */ -interface IDisposeStateData { - state: ContentDecryptorState.Disposed; - data: null; -} +/** ContentDecryptor's internal data when in the `Disposed` state. */ +type IDisposeStateData = IContentDecryptorStateBase< + ContentDecryptorState.Disposed, + undefined, // isInitDataQueueLocked + undefined, // isMediaKeysAttached + null // data +>; /** ContentDecryptor's internal data when in the `Error` state. */ -interface IErrorStateData { - state: ContentDecryptorState.Error; - data: null; +type IErrorStateData = IContentDecryptorStateBase< + ContentDecryptorState.Error, + undefined, // isInitDataQueueLocked + undefined, // isMediaKeysAttached + null // data +>; + +/** Information linked to a session created by the `ContentDecryptor`. */ +interface IActiveSessionInfo { + /** + * Record associated to the session. + * Most notably, it allows both to identify the session as well as to + * anounce and find out which key ids are already handled. + */ + record : KeySessionRecord; + + /** Current keys' statuses linked that session. */ + keyStatuses : undefined | { + /** Key ids linked to keys that are "usable". */ + whitelisted : Uint8Array[]; + /** + * Key ids linked to keys that are not considered "usable". + * Content linked to those keys are not decipherable and may thus be + * fallbacked from. + */ + blacklisted : Uint8Array[]; + }; + + /** Source of the MediaKeySession linked to that record. */ + source : MediaKeySessionLoadingType; + + /** + * If different than `null`, all initialization data compatible with this + * processed initialization data has been blacklisted with this corresponding + * error. + */ + blacklistedSessionError : BlacklistedSessionError | null; } -interface IContentSessionInfo { - /** Initialization data which triggered the creation of this session. */ - initializationData : IInitializationDataInfo; - /** Last key update event received for that session. */ - lastKeyUpdate : ISharedReference; +/** + * Sent when the created (or already created) MediaKeys is attached to the + * current HTMLMediaElement element. + * On some peculiar devices, we have to wait for that step before the first + * media segments are to be pushed to avoid issues. + * Because this event is sent after a MediaKeys is created, you will always have + * a "created-media-keys" event before an "attached-media-keys" event. + */ +interface IAttachedMediaKeysData { + /** The MediaKeySystemAccess which allowed to create the MediaKeys instance. */ + mediaKeySystemAccess: MediaKeySystemAccess | + ICustomMediaKeySystemAccess; + /** The MediaKeys instance. */ + mediaKeys : MediaKeys | + ICustomMediaKeys; + stores : IMediaKeySessionStores; + options : IKeySystemOption; } diff --git a/src/core/decrypt/create_or_load_session.ts b/src/core/decrypt/create_or_load_session.ts index c8d7558107..33afeb0772 100644 --- a/src/core/decrypt/create_or_load_session.ts +++ b/src/core/decrypt/create_or_load_session.ts @@ -21,45 +21,11 @@ import createSession from "./create_session"; import { IInitializationDataInfo, IMediaKeySessionStores, + MediaKeySessionLoadingType, } from "./types"; -import cleanOldLoadedSessions, { - ICleaningOldSessionDataPayload, -} from "./utils/clean_old_loaded_sessions"; +import cleanOldLoadedSessions from "./utils/clean_old_loaded_sessions"; import isSessionUsable from "./utils/is_session_usable"; - -/** Information concerning a MediaKeySession. */ -export interface IMediaKeySessionContext { - /** The MediaKeySession itself. */ - mediaKeySession : MediaKeySession | - ICustomMediaKeySession; - /** The type of MediaKeySession (e.g. "temporary"). */ - sessionType : MediaKeySessionType; - /** Initialization data assiociated to this MediaKeySession. */ - initializationData : IInitializationDataInfo; -} - -/** Event emitted when a new MediaKeySession has been created. */ -export interface ICreatedSession { - type : "created-session"; - value : IMediaKeySessionContext; -} - -/** Event emitted when an already-loaded MediaKeySession is used. */ -export interface ILoadedOpenSession { - type : "loaded-open-session"; - value : IMediaKeySessionContext; -} - -/** Event emitted when a persistent MediaKeySession has been loaded. */ -export interface ILoadedPersistentSessionEvent { - type : "loaded-persistent-session"; - value : IMediaKeySessionContext; -} - -/** Every possible result returned by `getSession`. */ -export type IGetSessionResult = ICreatedSession | - ILoadedOpenSession | - ILoadedPersistentSessionEvent; +import KeySessionRecord from "./utils/key_session_record"; /** * Handle MediaEncryptedEvents sent by a HTMLMediaElement: @@ -75,53 +41,49 @@ export type IGetSessionResult = ICreatedSession | * @param {Object} stores * @param {string} wantedSessionType * @param {number} maxSessionCacheSize - * @param {Function} onCleaningSession * @param {Object} cancelSignal - * @returns {Observable} + * @returns {Promise} */ -export default async function getSession( +export default async function createOrLoadSession( initializationData : IInitializationDataInfo, stores : IMediaKeySessionStores, wantedSessionType : MediaKeySessionType, maxSessionCacheSize : number, - onCleaningSession : (arg : ICleaningOldSessionDataPayload) => void, cancelSignal : CancellationSignal -) : Promise { - /** - * Store previously-loaded MediaKeySession with the same initialization data, if one. - */ +) : Promise { + /** Store previously-loaded compatible MediaKeySession, if one. */ let previousLoadedSession : MediaKeySession | ICustomMediaKeySession | null = null; const { loadedSessionsStore, persistentSessionsStore } = stores; - const entry = loadedSessionsStore.getAndReuse(initializationData); + const entry = loadedSessionsStore.reuse(initializationData); if (entry !== null) { previousLoadedSession = entry.mediaKeySession; if (isSessionUsable(previousLoadedSession)) { log.info("DRM: Reuse loaded session", previousLoadedSession.sessionId); - return { type: "loaded-open-session" as const, + return { type: MediaKeySessionLoadingType.LoadedOpenSession, value: { mediaKeySession: previousLoadedSession, sessionType: entry.sessionType, - initializationData } }; + keySessionRecord: entry.keySessionRecord } }; } else if (persistentSessionsStore !== null) { // If the session is not usable anymore, we can also remove it from the // PersistentSessionsStore. // TODO Are we sure this is always what we want? - persistentSessionsStore.delete(initializationData); + if (entry.mediaKeySession.sessionId !== "") { + persistentSessionsStore.delete(entry.mediaKeySession.sessionId); + } } } - if (previousLoadedSession) { - await loadedSessionsStore.closeSession(initializationData); + if (previousLoadedSession !== null) { + await loadedSessionsStore.closeSession(previousLoadedSession); if (cancelSignal.cancellationError !== null) { throw cancelSignal.cancellationError; // stop here if cancelled since } } - await cleanOldLoadedSessions(loadedSessionsStore, - maxSessionCacheSize, - onCleaningSession); + await cleanOldLoadedSessions(loadedSessionsStore, maxSessionCacheSize); if (cancelSignal.cancellationError !== null) { throw cancelSignal.cancellationError; // stop here if cancelled since } @@ -130,5 +92,39 @@ export default async function getSession( return { type: evt.type, value: { mediaKeySession: evt.value.mediaKeySession, sessionType: evt.value.sessionType, - initializationData } }; + keySessionRecord: evt.value.keySessionRecord } }; } + +/** Information concerning a MediaKeySession. */ +export interface IMediaKeySessionContext { + /** The MediaKeySession itself. */ + mediaKeySession : MediaKeySession | + ICustomMediaKeySession; + /** The type of MediaKeySession (e.g. "temporary"). */ + sessionType : MediaKeySessionType; + /** `KeySessionRecord` assiociated to this MediaKeySession. */ + keySessionRecord : KeySessionRecord; +} + +/** Event emitted when a new MediaKeySession has been created. */ +export interface ICreatedSession { + type : MediaKeySessionLoadingType.Created; + value : IMediaKeySessionContext; +} + +/** Event emitted when an already-loaded MediaKeySession is used. */ +export interface ILoadedOpenSession { + type : MediaKeySessionLoadingType.LoadedOpenSession; + value : IMediaKeySessionContext; +} + +/** Event emitted when a persistent MediaKeySession has been loaded. */ +export interface ILoadedPersistentSessionEvent { + type : MediaKeySessionLoadingType.LoadedPersistentSession; + value : IMediaKeySessionContext; +} + +/** Every possible result returned by `createOrLoadSession`. */ +export type ICreateOrLoadSessionResult = ICreatedSession | + ILoadedOpenSession | + ILoadedPersistentSessionEvent; diff --git a/src/core/decrypt/create_session.ts b/src/core/decrypt/create_session.ts index b23f1b87b0..3aeaf2af31 100644 --- a/src/core/decrypt/create_session.ts +++ b/src/core/decrypt/create_session.ts @@ -23,60 +23,46 @@ import log from "../../log"; import { IInitializationDataInfo, IMediaKeySessionStores, + MediaKeySessionLoadingType, } from "./types"; import isSessionUsable from "./utils/is_session_usable"; +import KeySessionRecord from "./utils/key_session_record"; import LoadedSessionsStore from "./utils/loaded_sessions_store"; import PersistentSessionsStore from "./utils/persistent_sessions_store"; -export interface INewSessionCreatedEvent { - type : "created-session"; - value : { mediaKeySession : MediaKeySession | - ICustomMediaKeySession; - sessionType : MediaKeySessionType; }; -} - -export interface IPersistentSessionRecoveryEvent { - type : "loaded-persistent-session"; - value : { mediaKeySession : MediaKeySession | - ICustomMediaKeySession; - sessionType : MediaKeySessionType; }; -} - -export type ICreateSessionEvent = INewSessionCreatedEvent | - IPersistentSessionRecoveryEvent; - /** - * Create a new Session on the given MediaKeys, corresponding to the given - * initializationData. + * Create a new Session or load a persistent one on the given MediaKeys, + * according to wanted settings and what is currently stored. + * * If session creating fails, remove the oldest MediaKeySession loaded and * retry. * * /!\ This only creates new sessions. * It will fail if loadedSessionsStore already has a MediaKeySession with - * the given initializationData. - * @param {Uint8Array} initData - * @param {string|undefined} initDataType - * @param {Object} mediaKeysInfos + * the given initialization data. + * @param {Object} stores + * @param {Object} initData + * @param {string} wantedSessionType * @returns {Promise} */ export default function createSession( stores : IMediaKeySessionStores, - initializationData : IInitializationDataInfo, + initData : IInitializationDataInfo, wantedSessionType : MediaKeySessionType ) : Promise { const { loadedSessionsStore, persistentSessionsStore } = stores; if (wantedSessionType === "temporary") { - return createTemporarySession(loadedSessionsStore, initializationData); + return createTemporarySession(loadedSessionsStore, initData); } else if (persistentSessionsStore === null) { log.warn("DRM: Cannot create persistent MediaKeySession, " + "PersistentSessionsStore not created."); - return createTemporarySession(loadedSessionsStore, initializationData); + return createTemporarySession(loadedSessionsStore, initData); } return createAndTryToRetrievePersistentSession(loadedSessionsStore, persistentSessionsStore, - initializationData); + initData); } /** @@ -91,15 +77,14 @@ function createTemporarySession( initData : IInitializationDataInfo ) : Promise { log.info("DRM: Creating a new temporary session"); - const session = loadedSessionsStore.createSession(initData, "temporary"); - return PPromise.resolve({ type: "created-session" as const, - value: { mediaKeySession: session, - sessionType: "temporary" as const } }); + const entry = loadedSessionsStore.createSession(initData, "temporary"); + return PPromise.resolve({ type: MediaKeySessionLoadingType.Created, + value: entry }); } /** * Create a persistent MediaKeySession and try to load on it a previous - * MediaKeySession linked to the same initData and initDataType. + * MediaKeySession linked to the same initialization data. * @param {Object} loadedSessionsStore * @param {Object} persistentSessionsStore * @param {Object} initData @@ -112,30 +97,28 @@ async function createAndTryToRetrievePersistentSession( ) : Promise { log.info("DRM: Creating persistent MediaKeySession"); - const session = loadedSessionsStore.createSession(initData, "persistent-license"); + const entry = loadedSessionsStore.createSession(initData, "persistent-license"); const storedEntry = persistentSessionsStore.getAndReuse(initData); if (storedEntry === null) { - return { type: "created-session" as const, - value: { mediaKeySession: session, - sessionType: "persistent-license" as const } }; + return { type: MediaKeySessionLoadingType.Created, + value: entry }; } try { - const hasLoadedSession = await loadSession(session, storedEntry.sessionId); + const hasLoadedSession = await loadSession(entry.mediaKeySession, + storedEntry.sessionId); if (!hasLoadedSession) { log.warn("DRM: No data stored for the loaded session"); - persistentSessionsStore.delete(initData); - return { type: "created-session" as const, - value: { mediaKeySession: session, - sessionType: "persistent-license" } }; + persistentSessionsStore.delete(storedEntry.sessionId); + return { type: MediaKeySessionLoadingType.Created, + value: entry }; } - if (hasLoadedSession && isSessionUsable(session)) { - persistentSessionsStore.add(initData, session); + if (hasLoadedSession && isSessionUsable(entry.mediaKeySession)) { + persistentSessionsStore.add(initData, initData.keyIds, entry.mediaKeySession); log.info("DRM: Succeeded to load persistent session."); - return { type: "loaded-persistent-session" as const, - value: { mediaKeySession: session, - sessionType: "persistent-license" } }; + return { type: MediaKeySessionLoadingType.LoadedPersistentSession, + value: entry }; } // Unusable persistent session: recreate a new session from scratch. @@ -155,15 +138,38 @@ async function createAndTryToRetrievePersistentSession( */ async function recreatePersistentSession() : Promise { log.info("DRM: Removing previous persistent session."); - if (persistentSessionsStore.get(initData) !== null) { - persistentSessionsStore.delete(initData); + const persistentEntry = persistentSessionsStore.get(initData); + if (persistentEntry !== null) { + persistentSessionsStore.delete(persistentEntry.sessionId); } - await loadedSessionsStore.closeSession(initData); - const newSession = loadedSessionsStore.createSession(initData, - "persistent-license"); - return { type: "created-session" as const, - value: { mediaKeySession: newSession, - sessionType: "persistent-license" } }; + await loadedSessionsStore.closeSession(entry.mediaKeySession); + const newEntry = loadedSessionsStore.createSession(initData, + "persistent-license"); + return { type: MediaKeySessionLoadingType.Created, + value: newEntry }; } } + +export interface INewSessionCreatedEvent { + type : MediaKeySessionLoadingType.Created; + value : { + mediaKeySession : MediaKeySession | + ICustomMediaKeySession; + sessionType : MediaKeySessionType; + keySessionRecord : KeySessionRecord; + }; +} + +export interface IPersistentSessionRecoveryEvent { + type : MediaKeySessionLoadingType.LoadedPersistentSession; + value : { + mediaKeySession : MediaKeySession | + ICustomMediaKeySession; + sessionType : MediaKeySessionType; + keySessionRecord : KeySessionRecord; + }; +} + +export type ICreateSessionEvent = INewSessionCreatedEvent | + IPersistentSessionRecoveryEvent; diff --git a/src/core/decrypt/session_events_listener.ts b/src/core/decrypt/session_events_listener.ts index d104e649d6..e63c94017b 100644 --- a/src/core/decrypt/session_events_listener.ts +++ b/src/core/decrypt/session_events_listener.ts @@ -51,10 +51,8 @@ import retryObsWithBackoff, { import tryCatch from "../../utils/rx-try_catch"; import { IEMEWarningEvent, - IKeyMessageHandledEvent, - IKeyStatusChangeHandledEvent, IKeySystemOption, - IKeysUpdateEvent, + ILicense, } from "./types"; import checkKeyStatuses, { IKeyStatusesCheckingOptions, @@ -335,3 +333,64 @@ function getLicenseBackoffOptions( value: formatGetLicenseError(error) }), }; } + +/** + * Some key ids have updated their status. + * + * We put them in two different list: + * + * - `blacklistedKeyIDs`: Those key ids won't be used for decryption and the + * corresponding media it decrypts should not be pushed to the buffer + * Note that a blacklisted key id can become whitelisted in the future. + * + * - `whitelistedKeyIds`: Those key ids were found and their corresponding + * keys are now being considered for decryption. + * Note that a whitelisted key id can become blacklisted in the future. + * + * Note that each `IKeysUpdateEvent` is independent of any other. + * + * A new `IKeysUpdateEvent` does not completely replace a previously emitted + * one, as it can for example be linked to a whole other decryption session. + * + * However, if a key id is encountered in both an older and a newer + * `IKeysUpdateEvent`, only the older status should be considered. + */ +export interface IKeysUpdateEvent { + type: "keys-update"; + value: IKeyUpdateValue; +} + +/** Information on key ids linked to a MediaKeySession. */ +export interface IKeyUpdateValue { + /** + * The list of key ids that are blacklisted. + * As such, their corresponding keys won't be used by that session, despite + * the fact that they were part of the pushed license. + * + * Reasons for blacklisting a keys depend on options, but mainly involve unmet + * output restrictions and CDM internal errors linked to that key id. + */ + blacklistedKeyIDs : Uint8Array[]; + /* + * The list of key id linked to that session which are not blacklisted. + * Together with `blacklistedKeyIDs` it regroups all key ids linked to the + * session. + */ + whitelistedKeyIds : Uint8Array[]; +} + +/** Emitted after the `onKeyStatusesChange` callback has been called. */ +interface IKeyStatusChangeHandledEvent { + type: "key-status-change-handled"; + value: { session: MediaKeySession | + ICustomMediaKeySession; + license: ILicense|null; }; +} + +/** Emitted after the `getLicense` callback has been called */ +interface IKeyMessageHandledEvent { + type: "key-message-handled"; + value: { session: MediaKeySession | + ICustomMediaKeySession; + license: ILicense|null; }; +} diff --git a/src/core/decrypt/types.ts b/src/core/decrypt/types.ts index 62b8af9e75..22f73a4322 100644 --- a/src/core/decrypt/types.ts +++ b/src/core/decrypt/types.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import { - ICustomMediaKeys, - ICustomMediaKeySession, - ICustomMediaKeySystemAccess, -} from "../../compat"; +import { ICustomMediaKeySession } from "../../compat"; import { ICustomError } from "../../errors"; -import { ISharedReference } from "../../utils/reference"; +import Manifest, { + Adaptation, + Period, + Representation, +} from "../../manifest"; import LoadedSessionsStore from "./utils/loaded_sessions_store"; import PersistentSessionsStore from "./utils/persistent_sessions_store"; @@ -41,167 +41,45 @@ export interface IInitializationDataInfo { * just mean that there's no key id involved). */ keyIds? : Uint8Array[]; + /** The content linked to that segment protection data. */ + content? : IContent; /** Every initialization data for that type. */ - values: Array<{ - /** - * Hex encoded system id, which identifies the key system. - * https://dashif.org/identifiers/content_protection/ - * - * If `undefined`, we don't know the system id for that initialization data. - * In that case, the initialization data might even be a concatenation of - * the initialization data from multiple system ids. - */ - systemId: string | undefined; - /** - * The initialization data itself for that type and systemId. - * For example, with "cenc" initialization data found in an ISOBMFF file, - * this will be the whole PSSH box. - */ - data: Uint8Array; - }>; -} - -/** Event emitted when a minor - recoverable - error happened. */ -export interface IEMEWarningEvent { type : "warning"; - value : ICustomError; } - -/** - * Event emitted when we receive an "encrypted" event from the browser. - * This is usually sent when pushing an initialization segment, if it stores - * encryption information. - */ -export interface IEncryptedEvent { type: "encrypted-event-received"; - value: IInitializationDataInfo; } - -/** - * Sent when a MediaKeys has been created (or is already created) for the - * current content. - * This is necessary before creating a MediaKeySession which will allow - * encryption keys to be communicated. - * - * It carries a shared reference (`canAttachMediaKeys`) that should be setted to - * `true` to indicate that RxPlayer's EME logic can start to attach the - * `MediaKeys` instance to the HTMLMediaElement. - */ -export interface ICreatedMediaKeysEvent { - type: "created-media-keys"; - value: { - /** The MediaKeySystemAccess which allowed to create the MediaKeys instance. */ - mediaKeySystemAccess: MediaKeySystemAccess | - ICustomMediaKeySystemAccess; - - /** - * Hex-encoded identifier for the key system used. - * A list of available IDs can be found here: - * https://dashif.org/identifiers/content_protection/ - * - * This ID can be used to select the encryption initialization data to send - * to the `ContentDecryptor`. - * - * Note that this is only for optimization purposes (e.g. to not - * unnecessarily wait for new encryption initialization data to arrive when - * those linked to the right key system is already available) as sending all - * available encryption initialization data should also work in all cases. - * - * Can be `undefined` in two cases: - * - * - the current system ID is not known - * - * - the current system ID is known, but we don't want to communicate it - * to ensure all encryption initialization data is still sent. - * This is usually done to work-around retro-compatibility issues with - * older persisted decryption session. - */ - initializationDataSystemId : string | undefined; - - /** The MediaKeys instance. */ - mediaKeys : MediaKeys | - ICustomMediaKeys; - - /** Stores allowing to cache MediaKeySession instances. */ - stores : IMediaKeySessionStores; - - /** key system options considered. */ - options : IKeySystemOption; - - /** - * Shared reference that should be set to `true` once the `MediaKeys` - * instance can be attached to the HTMLMediaElement. - */ - canAttachMediaKeys: ISharedReference; - }; + values: IInitializationDataInfoValue[]; } /** - * Sent when the created (or already created) MediaKeys is attached to the - * current HTMLMediaElement element. - * On some peculiar devices, we have to wait for that step before the first - * media segments are to be pushed to avoid issues. - * Because this event is sent after a MediaKeys is created, you will always have - * a "created-media-keys" event before an "attached-media-keys" event. + * Represent the initialization data linked to a key system that can be used to + * generate the license request. */ -export interface IAttachedMediaKeysData { - /** The MediaKeySystemAccess which allowed to create the MediaKeys instance. */ - mediaKeySystemAccess: MediaKeySystemAccess | - ICustomMediaKeySystemAccess; - /** The MediaKeys instance. */ - mediaKeys : MediaKeys | - ICustomMediaKeys; - stores : IMediaKeySessionStores; - options : IKeySystemOption; -} - - -/** - * Some key ids have updated their status. - * - * We put them in two different list: - * - * - `blacklistedKeyIDs`: Those key ids won't be used for decryption and the - * corresponding media it decrypts should not be pushed to the buffer - * Note that a blacklisted key id can become whitelisted in the future. - * - * - `whitelistedKeyIds`: Those key ids were found and their corresponding - * keys are now being considered for decryption. - * Note that a whitelisted key id can become blacklisted in the future. - * - * Note that each `IKeysUpdateEvent` is independent of any other. - * - * A new `IKeysUpdateEvent` does not completely replace a previously emitted - * one, as it can for example be linked to a whole other decryption session. - * - * However, if a key id is encountered in both an older and a newer - * `IKeysUpdateEvent`, only the older status should be considered. - */ -export interface IKeysUpdateEvent { - type: "keys-update"; - value: IKeyUpdateValue; -} - -/** Information on key ids linked to a MediaKeySession. */ -export interface IKeyUpdateValue { +export interface IInitializationDataInfoValue { /** - * The list of key ids that are blacklisted. - * As such, their corresponding keys won't be used by that session, despite - * the fact that they were part of the pushed license. + * Hex encoded system id, which identifies the key system. + * https://dashif.org/identifiers/content_protection/ * - * Reasons for blacklisting a keys depend on options, but mainly involve unmet - * output restrictions and CDM internal errors linked to that key id. + * If `undefined`, we don't know the system id for that initialization data. + * In that case, the initialization data might even be a concatenation of + * the initialization data from multiple system ids. */ - blacklistedKeyIDs : Uint8Array[]; - /* - * The list of key id linked to that session which are not blacklisted. - * Together with `blacklistedKeyIDs` it regroups all key ids linked to the - * session. + systemId: string | undefined; + /** + * The initialization data itself for that type and systemId. + * For example, with "cenc" initialization data found in an ISOBMFF file, + * this will be the whole PSSH box. */ - whitelistedKeyIds : Uint8Array[]; + data: Uint8Array; } +/** Event emitted when a minor - recoverable - error happened. */ +export interface IEMEWarningEvent { type : "warning"; + value : ICustomError; } + export type ILicense = BufferSource | ArrayBuffer; /** Segment protection sent by the RxPlayer to the `ContentDecryptor`. */ export interface IContentProtection { + /** The content linked to that segment protection data. */ + content : IContent; /** * Initialization data type. * String describing the format of the initialization data sent through this @@ -217,7 +95,7 @@ export interface IContentProtection { * `undefined` when not known (different from an empty array - which would * just mean that there's no key id involved). */ - keyIds? : Uint8Array[]; + keyIds : Uint8Array[] | undefined; /** Every initialization data for that type. */ values: Array<{ /** @@ -234,23 +112,16 @@ export interface IContentProtection { }>; } -// Emitted after the `onKeyStatusesChange` callback has been called -export interface IKeyStatusChangeHandledEvent { type: "key-status-change-handled"; - value: { session: MediaKeySession | - ICustomMediaKeySession; - license: ILicense|null; }; } - -// Emitted after the `getLicense` callback has been called -export interface IKeyMessageHandledEvent { type: "key-message-handled"; - value: { session: MediaKeySession | - ICustomMediaKeySession; - license: ILicense|null; }; } - -// Infos indentifying a MediaKeySystemAccess -export interface IKeySystemAccessInfos { - keySystemAccess: MediaKeySystemAccess | - ICustomMediaKeySystemAccess; - keySystemOptions: IKeySystemOption; +/** Content linked to protection data. */ +export interface IContent { + /** Manifest object associated to the protection data. */ + manifest : Manifest; + /** Period object associated to the protection data. */ + period : Period; + /** Adaptation object associated to the protection data. */ + adaptation : Adaptation; + /** Representation object associated to the protection data. */ + representation : Representation; } /** Stores helping to create and retrieve MediaKeySessions. */ @@ -262,19 +133,46 @@ export interface IMediaKeySessionStores { null; } +/** Enum identifying the way a new MediaKeySession has been loaded. */ +export const enum MediaKeySessionLoadingType { + /** + * This MediaKeySession has just been created. + * This means that it will necessitate a new license request to be generated + * and performed. + */ + Created = "created-session", + /** + * This MediaKeySession was an already-opened one that is being reused. + * Such session had already their license loaded and pushed. + */ + LoadedOpenSession = "loaded-open-session", + /** + * This MediaKeySession was a persistent MediaKeySession that has been + * re-loaded. + * Such session are linked to a persistent license which should have already + * been fetched. + */ + LoadedPersistentSession = "loaded-persistent-session", +} + /** * Data stored in a persistent MediaKeySession storage. * Has to be versioned to be able to play MediaKeySessions persisted in an old * RxPlayer version when in a new one. */ -export type IPersistentSessionInfo = IPersistentSessionInfoV3 | +export type IPersistentSessionInfo = IPersistentSessionInfoV4 | + IPersistentSessionInfoV3 | IPersistentSessionInfoV2 | IPersistentSessionInfoV1 | IPersistentSessionInfoV0; -/** Wrap initialization data and allow linearization of it into base64. */ -interface IInitDataContainer { - /** The initData itself. */ +/** Wrap an Uint8Array and allow serialization of it into base64. */ +interface ByteArrayContainer { + /** + * The wrapped data. + * Here named `initData` even when it's not always initialization data for + * backward-compatible reasons. + */ initData : Uint8Array; /** @@ -288,7 +186,53 @@ interface IInitDataContainer { /** * Stored information about a single persistent `MediaKeySession`, when created - * since the v3.24.0 RxPlayer version included. + * since the v3.27.0 RxPlayer version included. + */ +export interface IPersistentSessionInfoV4 { + /** Version for this object. */ + version : 4; + + /** The persisted MediaKeySession's `id`. Used to load it at a later time. */ + sessionId : string; + + /** Type giving information about the format of the initialization data. */ + initDataType : string | undefined; + + /** All key ids linked to that `MediaKeySession`. */ + keyIds: ByteArrayContainer[]; + + /** + * Every saved initialization data for that session, used as IDs. + * Elements are sorted in systemId alphabetical order (putting the `undefined` + * ones last). + */ + values: Array<{ + /** + * Hex encoded system id, which identifies the key system. + * https://dashif.org/identifiers/content_protection/ + * + * If `undefined`, we don't know the system id for that initialization data. + * In that case, the initialization data might even be a concatenation of + * the initialization data from multiple system ids. + */ + systemId : string | undefined; + /** + * A hash of the initialization data (generated by the `hashBuffer` function, + * at the time of v3.20.1 at least). Allows for a faster comparison than just + * comparing initialization data multiple times. + */ + hash : number; + /** + * The initialization data associated to the systemId, wrapped in a + * container to allow efficient serialization. + */ + data : ByteArrayContainer; + }>; +} + +/** + * Stored information about a single persistent `MediaKeySession`, when created + * from the v3.24.0 RxPlayer version included to the v3.26.2 included. */ export interface IPersistentSessionInfoV3 { /** Version for this object. */ @@ -325,14 +269,14 @@ export interface IPersistentSessionInfoV3 { * The initialization data associated to the systemId, wrapped in a * container to allow efficient serialization. */ - data : IInitDataContainer; + data : ByteArrayContainer; }>; } /** * Stored information about a single persistent `MediaKeySession`, when created * between the RxPlayer versions v3.21.0 and v3.21.1 included. - * The previous implementation (version 1) was fine enough but did not linearize + * The previous implementation (version 1) was fine enough but did not serialize * well due to it containing an Uint8Array. This data is now wrapped into a * container which will convert it to base64 when linearized through * `JSON.stringify`. @@ -346,7 +290,7 @@ export interface IPersistentSessionInfoV2 { * The initialization data associated to the `MediaKeySession`, wrapped in a * container to allow efficient linearization. */ - initData : IInitDataContainer; + initData : ByteArrayContainer; /** * A hash of the initialization data (generated by the `hashBuffer` function, * at the time of v3.20.1 at least). Allows for a faster comparison than just @@ -404,7 +348,7 @@ export interface IPersistentSessionInfoV0 { /** Persistent MediaKeySession storage interface. */ export interface IPersistentSessionStorage { /** Load persistent MediaKeySessions previously saved through the `save` callback. */ - load() : IPersistentSessionInfo[]; + load() : IPersistentSessionInfo[] | undefined | null; /** * Save new persistent MediaKeySession information. * The given argument should be returned by the next `load` call. diff --git a/src/core/decrypt/utils/__tests__/clean_old_loaded_sessions.test.ts b/src/core/decrypt/utils/__tests__/clean_old_loaded_sessions.test.ts index 5e93b14da9..f852974bed 100644 --- a/src/core/decrypt/utils/__tests__/clean_old_loaded_sessions.test.ts +++ b/src/core/decrypt/utils/__tests__/clean_old_loaded_sessions.test.ts @@ -27,17 +27,17 @@ import LoadedSessionsStore from "../loaded_sessions_store"; const entry1 = { initializationData: { data: new Uint8Array([1, 6, 9]), type: "test" }, - mediaKeySession: {}, + mediaKeySession: { sessionId: "toto" }, sessionType: "" }; const entry2 = { initializationData: { data: new Uint8Array([4, 8]), type: "foo" }, - mediaKeySession: {}, + mediaKeySession: { sessionId: "titi" }, sessionType: "" }; const entry3 = { initializationData: { data: new Uint8Array([7, 3, 121, 87]), type: "bar" }, - mediaKeySession: {}, + mediaKeySession: { sessionId: "tutu" }, sessionType: "" }; function createLoadedSessionsStore() : LoadedSessionsStore { @@ -78,11 +78,7 @@ async function checkNothingHappen( limit : number ) : Promise { const closeSessionSpy = jest.spyOn(loadedSessionsStore, "closeSession"); - let itemNb = 0; - await cleanOldLoadedSessions(loadedSessionsStore, limit, () => { - itemNb++; - }); - expect(itemNb).toEqual(0); + await cleanOldLoadedSessions(loadedSessionsStore, limit); expect(closeSessionSpy).not.toHaveBeenCalled(); closeSessionSpy.mockRestore(); } @@ -91,32 +87,27 @@ async function checkNothingHappen( * Call `cleanOldLoadedSessions` with the given loadedSessionsStore, limit and * entries and make sure that: * - closeSession is called on the specific entries a single time - * - all right events are received in the right order each a single time * - it completes without an error * Call `done` when done. * @param {Object} loadedSessionsStore * @param {number} limit * @param {Array.} entries */ -function checkEntriesCleaned( +async function checkEntriesCleaned( loadedSessionsStore : LoadedSessionsStore, limit : number, - entries : Array<{ initializationData: unknown }> + entries : Array<{ sessionId: string }> ) : Promise { const closeSessionSpy = jest.spyOn(loadedSessionsStore, "closeSession"); - let itemNb = 0; - const prom = cleanOldLoadedSessions(loadedSessionsStore, limit, (evt) => { - itemNb++; - expect(evt).toEqual(entries[itemNb - 1]); - }).then(() => { - expect(itemNb).toEqual(entries.length); + const prom = cleanOldLoadedSessions(loadedSessionsStore, limit).then(() => { + expect(closeSessionSpy).toHaveBeenCalledTimes(entries.length); + closeSessionSpy.mockRestore(); }); expect(closeSessionSpy).toHaveBeenCalledTimes(entries.length); for (let i = 0; i < entries.length; i++) { expect(closeSessionSpy) - .toHaveBeenNthCalledWith(i + 1, entries[i].initializationData); + .toHaveBeenNthCalledWith(i + 1, entries[i]); } - closeSessionSpy.mockRestore(); return prom; } @@ -155,11 +146,14 @@ describe("core - decrypt - cleanOldLoadedSessions", () => { }); it("should remove some if the limit is inferior to the current length", async () => { - await checkEntriesCleaned(createLoadedSessionsStore(), 1, [ entry1, entry2 ]); - await checkEntriesCleaned(createLoadedSessionsStore(), 2, [ entry1 ]); + await checkEntriesCleaned(createLoadedSessionsStore(), 1, [ entry1.mediaKeySession, + entry2.mediaKeySession ]); + await checkEntriesCleaned(createLoadedSessionsStore(), 2, [ entry1.mediaKeySession ]); }); it("should remove all if the limit is equal to 0", async () => { - await checkEntriesCleaned(createLoadedSessionsStore(), 0, [ entry1, entry2, entry3 ]); + await checkEntriesCleaned(createLoadedSessionsStore(), 0, [ entry1.mediaKeySession, + entry2.mediaKeySession, + entry3.mediaKeySession ]); }); }); diff --git a/src/core/decrypt/utils/clean_old_loaded_sessions.ts b/src/core/decrypt/utils/clean_old_loaded_sessions.ts index 127de0ec07..ff25f758c9 100644 --- a/src/core/decrypt/utils/clean_old_loaded_sessions.ts +++ b/src/core/decrypt/utils/clean_old_loaded_sessions.ts @@ -14,25 +14,9 @@ * limitations under the License. */ -import { ICustomMediaKeySession } from "../../../compat"; import PPromise from "../../../utils/promise"; -import { IInitializationDataInfo } from "../types"; import LoadedSessionsStore from "./loaded_sessions_store"; -/** - * Data emitted when we are beginning to close an old MediaKeySession to - * respect the maximum limit of concurrent MediaKeySession active. - */ -export interface ICleaningOldSessionDataPayload { - /** The MediaKeySession that we are currently cleaning. */ - mediaKeySession : MediaKeySession | - ICustomMediaKeySession; - /** The type of MediaKeySession (e.g. "temporary"). */ - sessionType : MediaKeySessionType; - /** Initialization data assiociated to this MediaKeySession. */ - initializationData : IInitializationDataInfo; -} - /** * Close sessions from the loadedSessionsStore to allow at maximum `limit` * stored MediaKeySessions in it. @@ -44,8 +28,7 @@ export interface ICleaningOldSessionDataPayload { */ export default async function cleanOldLoadedSessions( loadedSessionsStore : LoadedSessionsStore, - limit : number, - onCleaningSession : (arg : ICleaningOldSessionDataPayload) => void + limit : number ) : Promise { if (limit < 0 || limit >= loadedSessionsStore.getLength()) { return ; @@ -56,8 +39,7 @@ export default async function cleanOldLoadedSessions( const toDelete = entries.length - limit; for (let i = 0; i < toDelete; i++) { const entry = entries[i]; - onCleaningSession(entry); - proms.push(loadedSessionsStore.closeSession(entry.initializationData)); + proms.push(loadedSessionsStore.closeSession(entry.mediaKeySession)); } await PPromise.all(proms); } diff --git a/src/core/decrypt/utils/is_session_usable.ts b/src/core/decrypt/utils/is_session_usable.ts index 9fa9fcadd2..fbccac15b8 100644 --- a/src/core/decrypt/utils/is_session_usable.ts +++ b/src/core/decrypt/utils/is_session_usable.ts @@ -22,7 +22,6 @@ import arrayIncludes from "../../../utils/array_includes"; * If all key statuses attached to session are valid (either not * "expired" or "internal-error"), return true. * If not, return false. - * @param {Uint8Array} initData * @param {MediaKeySession} loadedSession * @returns {MediaKeySession} */ diff --git a/src/core/decrypt/utils/key_id_comparison.ts b/src/core/decrypt/utils/key_id_comparison.ts new file mode 100644 index 0000000000..0b838d8f4d --- /dev/null +++ b/src/core/decrypt/utils/key_id_comparison.ts @@ -0,0 +1,58 @@ +/** + * Copyright 2015 CANAL+ Group + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import areArraysOfNumbersEqual from "../../../utils/are_arrays_of_numbers_equal"; + +/** + * Returns `true` if all key ids in `wantedKeyIds` are present in the + * `keyIdsArr` array. + * @param {Array.} wantedKeyIds + * @param {Array.} keyIdsArr + * @returns {boolean} + */ +export function areAllKeyIdContainedIn( + wantedKeyIds : Uint8Array[], + keyIdsArr : Uint8Array[] +) : boolean { + for (const keyId of wantedKeyIds) { + const found = keyIdsArr.some(k => areArraysOfNumbersEqual(k, keyId)); + if (!found) { + return false; + } + } + return true; +} + +/** + * Returns `true` if at least one key id in `wantedKeyIds` is present in the + * `keyIdsArr` array. + * @param {Array.} wantedKeyIds + * @param {Array.} keyIdsArr + * @returns {boolean} + */ +export function areSomeKeyIdContainedIn( + wantedKeyIds : Uint8Array[], + keyIdsArr : Uint8Array[] +) : boolean { + for (const keyId of wantedKeyIds) { + const found = keyIdsArr.some(k => areArraysOfNumbersEqual(k, keyId)); + if (found) { + return true; + } + } + return false; +} + diff --git a/src/core/decrypt/utils/key_session_record.ts b/src/core/decrypt/utils/key_session_record.ts new file mode 100644 index 0000000000..b09b1e9211 --- /dev/null +++ b/src/core/decrypt/utils/key_session_record.ts @@ -0,0 +1,207 @@ +/** + * Copyright 2015 CANAL+ Group + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import areArraysOfNumbersEqual from "../../../utils/are_arrays_of_numbers_equal"; +import hashBuffer from "../../../utils/hash_buffer"; +import { + IInitializationDataInfo, + IInitializationDataInfoValue, +} from "../types"; +import areInitializationValuesCompatible from "./are_init_values_compatible"; +import { areAllKeyIdContainedIn } from "./key_id_comparison"; + +/** + * Cache preventing initialization data to be formatted into an + * `IFormattedInitDataValue` multiple times. + * /!\ Because this WeakMap is based on the `IFormattedInitDataValue`'s + * reference, it will only work if the initialization data's values are NEVER + * MUTATED. + * Else, all kinds of strange issues could appear. + * TODO find another solution without this risk? + */ +const memoizedFormattedInitDataValues = new WeakMap(); + +/** + * Class storing key-related information linked to a created `MediaKeySession`. + * + * This class allows to regroup one or multiple key ids and can be linked to a + * single MediaKeySession so you can know which key that MediaKeySession + * handles. + * + * The main use case behind the complexities of this `KeySessionRecord` is to + * better handle the `singleLicensePer` RxPlayer option, which allows the + * recuperation of a license containing multiple keys, even if only one of + * those keys was asked for (which in turn allows to reduce the number of + * requests and to improve performance). + * Here, the `KeySessionRecord` will regroup all those key's id and can be + * linked to the corresponding MediaKeySession. + * That way, you can later check if another encrypted content is compatible with + * that session through the `KeySessionRecord`'s `isCompatibleWith` method. + * + * @example + * ```js + * const record = new KeySessionRecord(initData); + * + * // Create a MediaKeySession linked to that initialization data and fetch the + * // license + * // ... + * + * // Once the license has been loaded to the MediaKeySession linked to that + - // initialization data, associate the license's key Ids with the latter. + * record.associateKeyIds(someKeyIds); + * + * // Function called when new initialization data is encountered + * function onNewInitializationData(newInitializationData) { + * if (record.isCompatibleWith(newInitializationData)) { + * console.log("This initialization data should already be handled, ignored."); + * } else { + * console.log("This initialization data is not handled yet."; + * } + * } + * ``` + * @class KeySessionRecord + */ +export default class KeySessionRecord { + private readonly _initializationData : IInitializationDataInfo; + private _keyIds : Uint8Array[] | null; + + /** + * Create a new `KeySessionRecord`, linked to its corresponding initialization + * data, + * @param {Object} initializationData + */ + constructor(initializationData : IInitializationDataInfo) { + this._initializationData = initializationData; + this._keyIds = null; + } + + /** + * Associate supplementary key ids to this `KeySessionRecord` so it becomes + * "compatible" to them. + * + * After this call, new initialization data linked to subsets of those key + * ids will be considered compatible to this `KeySessionRecord` (calls to + * `isCompatibleWith` with the corresponding initialization data will return + * `true`). + * @param {Array.} keyIds + */ + public associateKeyIds(keyIds : Uint8Array[]) : void { + this._keyIds = []; + for (const keyId of keyIds) { + let alreadyPresent = false; + for (const storedKeyId of this._keyIds) { + if (storedKeyId === keyId || areArraysOfNumbersEqual(storedKeyId, keyId)) { + alreadyPresent = true; + break; + } + } + if (!alreadyPresent) { + this._keyIds.push(keyId); + } + } + } + + /** + * Check if that `KeySessionRecord` is compatible to the initialization data + * given. + * + * If it returns `true`, it means that this `KeySessionRecord` is already + * linked to that initialization data's key. As such, if that + * `KeySessionRecord` is already associated to an active MediaKeySession for + * example, the content linked to that initialization data should already be + * handled. + * + * If it returns `false`, it means that this `KeySessionRecord` has no + * relation with the given initialization data. + * + * @param {Object} initializationData + * @returns {boolean} + */ + public isCompatibleWith( + initializationData : IInitializationDataInfo + ) : boolean { + const { keyIds } = initializationData; + if (keyIds !== undefined) { + if (this._keyIds !== null && areAllKeyIdContainedIn(keyIds, this._keyIds)) { + return true; + } + if (this._initializationData.keyIds !== undefined) { + return areAllKeyIdContainedIn(keyIds, this._initializationData.keyIds); + } + } + return this._checkInitializationDataCompatibility(initializationData); + } + + private _checkInitializationDataCompatibility( + initializationData : IInitializationDataInfo + ) : boolean { + if (initializationData.keyIds !== undefined && + this._initializationData.keyIds !== undefined) + { + return areAllKeyIdContainedIn(initializationData.keyIds, + this._initializationData.keyIds); + } + + if (this._initializationData.type !== initializationData.type) { + return false; + } + + let formattedVals = memoizedFormattedInitDataValues.get(initializationData.values); + if (formattedVals === undefined) { + formattedVals = this._formatInitDataValues(initializationData.values); + memoizedFormattedInitDataValues.set(initializationData.values, + formattedVals); + } + + let selfVals = memoizedFormattedInitDataValues.get(this._initializationData.values); + if (selfVals === undefined) { + selfVals = this._formatInitDataValues(this._initializationData.values); + } + return areInitializationValuesCompatible(selfVals, formattedVals); + } + + /** + * Format given initializationData's values so they are faster to compare: + * - sort them by systemId + * - add hash for each initialization data encountered. + * @param {Array.} initialValues + * @returns {Array.} + */ + private _formatInitDataValues( + initialValues : Array<{ systemId : string | undefined; + data : Uint8Array; }> + ) : Array<{ systemId : string | undefined; + hash : number; + data : Uint8Array; }> { + return initialValues.slice() + .sort((a, b) => a.systemId === b.systemId ? 0 : + a.systemId === undefined ? 1 : + b.systemId === undefined ? -1 : + a.systemId < b.systemId ? -1 : + 1) + .map(({ systemId, data }) => ({ systemId, + data, + hash: hashBuffer(data) })); + } +} + +/** Formatted initialization data value, so it is faster to compart to others. */ +interface IFormattedInitDataValue { + systemId : string | undefined; + hash : number; + data : Uint8Array; +} diff --git a/src/core/decrypt/utils/loaded_sessions_store.ts b/src/core/decrypt/utils/loaded_sessions_store.ts index 80992585ca..a1eb20fade 100644 --- a/src/core/decrypt/utils/loaded_sessions_store.ts +++ b/src/core/decrypt/utils/loaded_sessions_store.ts @@ -26,31 +26,10 @@ import { onKeyStatusesChange$, } from "../../../compat/event_listeners"; import config from "../../../config"; -import { EncryptedMediaError } from "../../../errors"; import log from "../../../log"; import isNullOrUndefined from "../../../utils/is_null_or_undefined"; import { IInitializationDataInfo } from "../types"; -import InitDataStore from "./init_data_store"; - -/** Stored MediaKeySession data assiociated to an initialization data. */ -interface IStoredSessionEntry { - /** The initialization data linked to the MediaKeySession. */ - initializationData : IInitializationDataInfo; - /** The MediaKeySession created. */ - mediaKeySession : MediaKeySession | - ICustomMediaKeySession; - /** The MediaKeySessionType (e.g. "temporary" or "persistent-license"). */ - sessionType : MediaKeySessionType; -} - -/** MediaKeySession information. */ -export interface IStoredSessionData { - /** The MediaKeySession created. */ - mediaKeySession : MediaKeySession | - ICustomMediaKeySession; - /** The MediaKeySessionType (e.g. "temporary" or "persistent-license"). */ - sessionType : MediaKeySessionType; -} +import KeySessionRecord from "./key_session_record"; const { EME_SESSION_CLOSING_MAX_RETRY, EME_SESSION_CLOSING_INITIAL_DELAY, @@ -69,7 +48,7 @@ export default class LoadedSessionsStore { private readonly _mediaKeys : MediaKeys|ICustomMediaKeys; /** Store unique MediaKeySession information per initialization data. */ - private _storage : InitDataStore; + private _storage : IStoredSessionEntry[]; /** * Create a new LoadedSessionsStore, which will store information about @@ -78,89 +57,30 @@ export default class LoadedSessionsStore { */ constructor(mediaKeys : MediaKeys|ICustomMediaKeys) { this._mediaKeys = mediaKeys; - this._storage = new InitDataStore(); - } - - /** - * Returns the stored MediaKeySession information related to the - * given initDataType and initData if found. - * Returns `null` if no such MediaKeySession is stored. - * @param {Object} initializationData - * @returns {Object|null} - */ - public get(initializationData : IInitializationDataInfo) : IStoredSessionData | null { - const entry = this._storage.get(initializationData); - return entry === undefined ? null : - { mediaKeySession: entry.mediaKeySession, - sessionType: entry.sessionType }; - } - - /** - * Like `get` but also moves the corresponding MediaKeySession to the end of - * its internal storage, as returned by the `getAll` method. - * - * This can be used for example to tell when a previously-stored - * initialization data is re-used to then be able to implement a caching - * replacement algorithm based on the least-recently-used values by just - * evicting the first values returned by `getAll`. - * @param {Object} initializationData - * @returns {Object|null} - */ - public getAndReuse( - initializationData : IInitializationDataInfo - ) : IStoredSessionData | null { - const entry = this._storage.getAndReuse(initializationData); - return entry === undefined ? null : - { mediaKeySession: entry.mediaKeySession, - sessionType: entry.sessionType }; - } - - /** - * Moves the corresponding MediaKeySession to the end of its internal storage, - * as returned by the `getAll` method. - * - * This can be used to signal that a previously-stored initialization data is - * re-used to then be able to implement a caching replacement algorithm based - * on the least-recently-used values by just evicting the first values - * returned by `getAll`. - * - * Returns `true` if the corresponding session was found in the store, `false` - * otherwise. - * @param {Object} initializationData - * @returns {boolean} - */ - public reuse( - initializationData : IInitializationDataInfo - ) : boolean { - return this._storage.getAndReuse(initializationData) !== undefined; + this._storage = []; } /** * Create a new MediaKeySession and store it in this store. - * @throws {EncryptedMediaError} * @param {Object} initializationData * @param {string} sessionType - * @returns {MediaKeySession} + * @returns {Object} */ public createSession( - initializationData : IInitializationDataInfo, + initData : IInitializationDataInfo, sessionType : MediaKeySessionType - ) : MediaKeySession|ICustomMediaKeySession { - if (this._storage.get(initializationData) !== undefined) { - throw new EncryptedMediaError("MULTIPLE_SESSIONS_SAME_INIT_DATA", - "This initialization data was already stored."); - } - + ) : IStoredSessionEntry { + const keySessionRecord = new KeySessionRecord(initData); const mediaKeySession = this._mediaKeys.createSession(sessionType); - const entry = { mediaKeySession, sessionType, initializationData }; + const entry = { mediaKeySession, sessionType, keySessionRecord }; if (!isNullOrUndefined(mediaKeySession.closed)) { mediaKeySession.closed .then(() => { - const currentEntry = this._storage.get(initializationData); - if (currentEntry !== undefined && - currentEntry.mediaKeySession === mediaKeySession) + const index = this.getIndex(keySessionRecord); + if (index >= 0 && + this._storage[index].mediaKeySession === mediaKeySession) { - this._storage.remove(initializationData); + this._storage.splice(index, 1); } }) .catch((e : unknown) => { @@ -170,21 +90,55 @@ export default class LoadedSessionsStore { } log.debug("DRM-LSS: Add MediaKeySession", entry.sessionType); - this._storage.store(initializationData, entry); - return mediaKeySession; + this._storage.push({ keySessionRecord, mediaKeySession, sessionType }); + return entry; } /** - * Close a MediaKeySession corresponding to an initialization data and remove - * its related stored information from the LoadedSessionsStore. - * Emit when done. + * Find a stored entry compatible with the initialization data given and moves + * this entry at the end of the `LoadedSessionsStore`''s storage, returned by + * its `getAll` method. + * + * This can be used for example to tell when a previously-stored + * entry is re-used to then be able to implement a caching replacement + * algorithm based on the least-recently-used values by just evicting the first + * values returned by `getAll`. * @param {Object} initializationData - * @returns {Observable} + * @returns {Object|null} */ - public async closeSession( + public reuse( initializationData : IInitializationDataInfo + ) : IStoredSessionEntry | null { + for (let i = this._storage.length - 1; i >= 0; i--) { + const stored = this._storage[i]; + if (stored.keySessionRecord.isCompatibleWith(initializationData)) { + this._storage.splice(i, 1); + this._storage.push(stored); + return { keySessionRecord: stored.keySessionRecord, + mediaKeySession: stored.mediaKeySession, + sessionType: stored.sessionType }; + } + } + return null; + } + + /** + * Close a MediaKeySession and remove its related stored information from the + * `LoadedSessionsStore`. + * Emit when done. + * @param {Object} mediaKeySession + * @returns {Promise} + */ + public async closeSession( + mediaKeySession : MediaKeySession | ICustomMediaKeySession ) : Promise { - const entry = this._storage.remove(initializationData); + let entry; + for (const stored of this._storage) { + if (stored.mediaKeySession === mediaKeySession) { + entry = stored; + break; + } + } if (entry === undefined) { log.warn("DRM-LSS: No MediaKeySession found with " + "the given initData and initDataType"); @@ -199,7 +153,7 @@ export default class LoadedSessionsStore { * @returns {number} */ public getLength() : number { - return this._storage.getLength(); + return this._storage.length; } /** @@ -208,27 +162,65 @@ export default class LoadedSessionsStore { * @returns {Array.} */ public getAll() : IStoredSessionEntry[] { - return this._storage.getAll(); + return this._storage; } /** * Close all sessions in this store. * Emit `null` when done. - * @returns {PPromise} + * @returns {Promise} */ public async closeAllSessions() : Promise { - const allEntries = this._storage.getAll(); + const allEntries = this._storage; log.debug("DRM-LSS: Closing all current MediaKeySessions", allEntries.length); // re-initialize the storage, so that new interactions with the // `LoadedSessionsStore` do not rely on MediaKeySessions we're in the // process of removing - this._storage = new InitDataStore(); + this._storage = []; const closingProms = allEntries .map((entry) => safelyCloseMediaKeySession(entry.mediaKeySession)); await PPromise.all(closingProms); } + + private getIndex(record : KeySessionRecord) : number { + for (let i = 0; i < this._storage.length; i++) { + const stored = this._storage[i]; + if (stored.keySessionRecord === record) { + return i; + } + } + return -1; + } +} + +/** Information linked to a `MediaKeySession` created by the `LoadedSessionsStore`. */ +export interface IStoredSessionEntry { + /** + * The `KeySessionRecord` linked to the MediaKeySession. + * It keeps track of all key ids that are currently known to be associated to + * the MediaKeySession. + * + * Initially only assiociated with the initialization data given, you may want + * to add to it other key ids if you find out that there are also linked to + * that session. + * + * Regrouping all those key ids into the `KeySessionRecord` in that way allows + * the `LoadedSessionsStore` to perform compatibility checks when future + * initialization data is encountered. + */ + keySessionRecord : KeySessionRecord; + + /** The MediaKeySession created. */ + mediaKeySession : MediaKeySession | + ICustomMediaKeySession; + + /** + * The MediaKeySessionType (e.g. "temporary" or "persistent-license") with + * which the MediaKeySession was created. + */ + sessionType : MediaKeySessionType; } /** diff --git a/src/core/decrypt/utils/persistent_sessions_store.ts b/src/core/decrypt/utils/persistent_sessions_store.ts index a73834d8bf..351f61b9b3 100644 --- a/src/core/decrypt/utils/persistent_sessions_store.ts +++ b/src/core/decrypt/utils/persistent_sessions_store.ts @@ -101,10 +101,11 @@ export default class PersistentSessionsStore { this._entries = []; this._storage = storage; try { - this._entries = this._storage.load(); - if (!Array.isArray(this._entries)) { - this._entries = []; + let entries = this._storage.load(); + if (!Array.isArray(entries)) { + entries = []; } + this._entries = entries; } catch (e) { log.warn("DRM-PSS: Could not get entries from license storage", e); this.dispose(); @@ -130,7 +131,7 @@ export default class PersistentSessionsStore { /** * Retrieve an entry based on its initialization data. - * @param {Uint8Array} initData + * @param {Object} initData * @param {string|undefined} initDataType * @returns {Object|null} */ @@ -169,6 +170,8 @@ export default class PersistentSessionsStore { */ public add( initData : IInitializationDataInfo, + // XXX TODO + _keyIds : Uint8Array[] | undefined, session : MediaKeySession|ICustomMediaKeySession ) : void { if (isNullOrUndefined(session) || !isNonEmptyString(session.sessionId)) { @@ -180,7 +183,7 @@ export default class PersistentSessionsStore { if (currentEntry !== null && currentEntry.sessionId === sessionId) { return; } else if (currentEntry !== null) { // currentEntry has a different sessionId - this.delete(initData); + this.delete(currentEntry.sessionId); } log.info("DRM-PSS: Add new session", sessionId); @@ -193,13 +196,19 @@ export default class PersistentSessionsStore { } /** - * Delete stored MediaKeySession information based on its initialization - * data. + * Delete stored MediaKeySession information based on its session id. * @param {Uint8Array} initData * @param {string|undefined} initDataType */ - public delete(initData : IInitializationDataInfo) : void { - const index = this._getIndex(initData); + public delete(sessionId : string) : void { + let index = -1; + for (let i = 0; i < this._entries.length; i++) { + const entry = this._entries[i]; + if (entry.sessionId === sessionId) { + index = i; + break; + } + } if (index === -1) { log.warn("DRM-PSS: initData to delete not found."); return; @@ -237,8 +246,7 @@ export default class PersistentSessionsStore { /** * Retrieve index of an entry. * Returns `-1` if not found. - * @param {Uint8Array} initData - * @param {string|undefined} initDataType + * @param {Object} initData * @returns {number} */ private _getIndex(initData : IInitializationDataInfo) : number { @@ -253,6 +261,8 @@ export default class PersistentSessionsStore { const entry = this._entries[i]; if (entry.initDataType === initData.type) { switch (entry.version) { + case 4: + throw new Error("XXX TODO Not implemented yet"); case 3: if (areInitializationValuesCompatible(formatted, entry.values)) { diff --git a/src/core/init/initialize_media_source.ts b/src/core/init/initialize_media_source.ts index a2f899b773..4e3f276c08 100644 --- a/src/core/init/initialize_media_source.ts +++ b/src/core/init/initialize_media_source.ts @@ -31,7 +31,6 @@ import { switchMap, take, takeUntil, - tap, } from "rxjs"; import { shouldReloadMediaSourceOnDecipherabilityUpdate } from "../../compat"; import config from "../../config"; @@ -274,20 +273,8 @@ export default function InitializeOnMediaSource( fromEvent(manifest, "decipherabilityUpdate") .pipe(map(EVENTS.decipherabilityUpdate))); - const setUndecipherableRepresentations$ = drmEvents$.pipe( - tap((evt) => { - if (evt.type === "keys-update") { - manifest.updateDeciperabilitiesBasedOnKeyIds(evt.value); - } else if (evt.type === "blacklist-protection-data") { - log.info("Init: blacklisting Representations based on protection data."); - manifest.addUndecipherableProtectionData(evt.value); - } - }), - ignoreElements()); - return observableMerge(manifestEvents$, manifestUpdate$, - setUndecipherableRepresentations$, recursiveLoad$) .pipe(startWith(EVENTS.manifestReady(manifest)), finalize(() => { scheduleRefresh$.complete(); })); diff --git a/src/core/init/link_drm_and_content.ts b/src/core/init/link_drm_and_content.ts index 4b9624b593..49a5b35303 100644 --- a/src/core/init/link_drm_and_content.ts +++ b/src/core/init/link_drm_and_content.ts @@ -29,9 +29,7 @@ import features from "../../features"; import log from "../../log"; import { IContentProtection, - IInitializationDataInfo, IKeySystemOption, - IKeysUpdateEvent, ContentDecryptorState, } from "../decrypt"; import { IWarningEvent } from "./types"; @@ -117,10 +115,6 @@ export default function linkDrmAndContent( } }); - contentDecryptor.addEventListener("keyUpdate", (e) => { - obs.next({ type: "keys-update", value: e }); - }); - contentDecryptor.addEventListener("error", (e) => { obs.error(e); }); @@ -129,10 +123,6 @@ export default function linkDrmAndContent( obs.next({ type: "warning", value: w }); }); - contentDecryptor.addEventListener("blacklistProtectionData", (e) => { - obs.next({ type: "blacklist-protection-data", value: e }); - }); - const protectionDataSub = contentProtections$.subscribe(data => { contentDecryptor.onInitializationData(data); }); @@ -147,8 +137,6 @@ export default function linkDrmAndContent( export type IContentDecryptorInitEvent = IDecryptionDisabledEvent | IDecryptionReadyEvent | - IKeysUpdateEvent | - IBlacklistProtectionDataEvent | IWarningEvent; /** @@ -186,15 +174,3 @@ export interface IDecryptionReadyEvent { mediaSource: T; }; } - -/** - * Event Emitted when specific "protection data" cannot be deciphered and is thus - * blacklisted. - * - * The linked value is the initialization data linked to the content that cannot - * be deciphered. - */ -export interface IBlacklistProtectionDataEvent { - type: "blacklist-protection-data"; - value: IInitializationDataInfo; -} diff --git a/src/core/stream/events_generators.ts b/src/core/stream/events_generators.ts index 846814ef54..53c3d2a983 100644 --- a/src/core/stream/events_generators.ts +++ b/src/core/stream/events_generators.ts @@ -16,13 +16,14 @@ import { Subject } from "rxjs"; import { ICustomError } from "../../errors"; -import { +import Manifest, { Adaptation, ISegment, Period, Representation, } from "../../manifest"; -import { IContentProtection } from "../decrypt"; +import { IRepresentationProtectionData } from "../../manifest/representation"; +import objectAssign from "../../utils/object_assign"; import { IBufferType } from "../segment_buffers"; import { IActivePeriodChangedEvent, @@ -169,10 +170,14 @@ const EVENTS = { }, encryptionDataEncountered( - initDataInfo : IContentProtection + reprProtData : IRepresentationProtectionData, + content : { manifest : Manifest; + period : Period; + adaptation : Adaptation; + representation : Representation; } ) : IEncryptionDataEncounteredEvent { return { type: "encryption-data-encountered", - value: initDataInfo }; + value: objectAssign({ content }, reprProtData) }; }, representationChange( diff --git a/src/core/stream/representation/representation_stream.ts b/src/core/stream/representation/representation_stream.ts index 93ca3c5087..556f9c4060 100644 --- a/src/core/stream/representation/representation_stream.ts +++ b/src/core/stream/representation/representation_stream.ts @@ -264,7 +264,7 @@ export default function RepresentationStream({ const encryptionData = representation.getEncryptionData(drmSystemId); if (encryptionData.length > 0) { encryptionEvent$ = observableOf(...encryptionData.map(d => - EVENTS.encryptionDataEncountered(d))); + EVENTS.encryptionDataEncountered(d, content))); hasSentEncryptionData = true; } } @@ -458,7 +458,7 @@ export default function RepresentationStream({ const initEncEvt$ = !hasSentEncryptionData && allEncryptionData.length > 0 ? observableOf(...allEncryptionData.map(p => - EVENTS.encryptionDataEncountered(p))) : + EVENTS.encryptionDataEncountered(p, content))) : EMPTY; const pushEvent$ = pushInitSegment({ playbackObserver, content, @@ -476,7 +476,7 @@ export default function RepresentationStream({ const segmentEncryptionEvent$ = protectionDataUpdate && !hasSentEncryptionData ? observableOf(...representation.getAllEncryptionData().map(p => - EVENTS.encryptionDataEncountered(p))) : + EVENTS.encryptionDataEncountered(p, content))) : EMPTY; const manifestRefresh$ = needsManifestRefresh === true ? diff --git a/src/manifest/manifest.ts b/src/manifest/manifest.ts index 3e38ab9afb..536722468f 100644 --- a/src/manifest/manifest.ts +++ b/src/manifest/manifest.ts @@ -14,13 +14,11 @@ * limitations under the License. */ -import { IInitializationDataInfo } from "../core/decrypt"; import { ICustomError, MediaError, } from "../errors"; import { IParsedManifest } from "../parsers/manifest"; -import areArraysOfNumbersEqual from "../utils/are_arrays_of_numbers_equal"; import arrayFind from "../utils/array_find"; import EventEmitter from "../utils/event_emitter"; import idGenerator from "../utils/id_generator"; @@ -502,73 +500,10 @@ export default class Manifest extends EventEmitter { * changes performed. * @param {Object} keyUpdates */ - public updateDeciperabilitiesBasedOnKeyIds( - { whitelistedKeyIds, - blacklistedKeyIDs } : { whitelistedKeyIds : Uint8Array[]; - blacklistedKeyIDs : Uint8Array[]; } + public updateRepresentationsDeciperability( + isDecipherableCb : (rep : Representation) => boolean | undefined ) : void { - const updates = updateDeciperability(this, (representation) => { - if (representation.decipherable === false || - representation.contentProtections === undefined) - { - return representation.decipherable; - } - const contentKIDs = representation.contentProtections.keyIds; - for (let i = 0; i < contentKIDs.length; i++) { - const elt = contentKIDs[i]; - for (let j = 0; j < blacklistedKeyIDs.length; j++) { - if (areArraysOfNumbersEqual(blacklistedKeyIDs[j], elt.keyId)) { - return false; - } - } - for (let j = 0; j < whitelistedKeyIds.length; j++) { - if (areArraysOfNumbersEqual(whitelistedKeyIds[j], elt.keyId)) { - return true; - } - } - } - return representation.decipherable; - }); - - if (updates.length > 0) { - this.trigger("decipherabilityUpdate", updates); - } - } - - /** - * Look in the Manifest for Representations linked to the given content - * protection initialization data and mark them as being impossible to - * decrypt. - * Then trigger a "decipherabilityUpdate" event to notify everyone of the - * changes performed. - * @param {Object} initData - */ - public addUndecipherableProtectionData(initData : IInitializationDataInfo) : void { - const updates = updateDeciperability(this, (representation) => { - if (representation.decipherable === false) { - return false; - } - const segmentProtections = representation.contentProtections?.initData ?? []; - for (let i = 0; i < segmentProtections.length; i++) { - if (initData.type === undefined || - segmentProtections[i].type === initData.type) - { - const containedInitData = initData.values.every(undecipherableVal => { - return segmentProtections[i].values.some(currVal => { - return (undecipherableVal.systemId === undefined || - currVal.systemId === undecipherableVal.systemId) && - areArraysOfNumbersEqual(currVal.data, - undecipherableVal.data); - }); - }); - if (containedInitData) { - return false; - } - } - } - return representation.decipherable; - }); - + const updates = updateDeciperability(this, isDecipherableCb); if (updates.length > 0) { this.trigger("decipherabilityUpdate", updates); } diff --git a/src/manifest/representation.ts b/src/manifest/representation.ts index 2b66b62da1..8c019e47c9 100644 --- a/src/manifest/representation.ts +++ b/src/manifest/representation.ts @@ -15,7 +15,6 @@ */ import { isCodecSupported } from "../compat"; -import { IContentProtection } from "../core/decrypt"; import log from "../log"; import { IContentProtections, @@ -166,7 +165,7 @@ class Representation { * @param {string} drmSystemId - The hexa-encoded DRM system ID * @returns {Array.} */ - public getEncryptionData(drmSystemId : string) : IContentProtection[] { + public getEncryptionData(drmSystemId : string) : IRepresentationProtectionData[] { const allInitData = this.getAllEncryptionData(); const filtered = []; for (let i = 0; i < allInitData.length; i++) { @@ -216,7 +215,7 @@ class Representation { * after parsing this Representation's initialization segment, if one exists. * @returns {Array.} */ - public getAllEncryptionData() : IContentProtection[] { + public getAllEncryptionData() : IRepresentationProtectionData[] { if (this.contentProtections === undefined || this.contentProtections.initData.length === 0) { @@ -297,4 +296,38 @@ class Representation { } } +/** Protection data as returned by a Representation. */ +export interface IRepresentationProtectionData { + /** + * Initialization data type. + * String describing the format of the initialization data sent through this + * event. + * https://www.w3.org/TR/eme-initdata-registry/ + */ + type: string; + /** + * The key ids linked to those initialization data. + * This should be the key ids for the key concerned by the media which have + * the present initialization data. + * + * `undefined` when not known (different from an empty array - which would + * just mean that there's no key id involved). + */ + keyIds : Uint8Array[] | undefined; + /** Every initialization data for that type. */ + values: Array<{ + /** + * Hex encoded system id, which identifies the key system. + * https://dashif.org/identifiers/content_protection/ + */ + systemId: string; + /** + * The initialization data itself for that type and systemId. + * For example, with "cenc" initialization data found in an ISOBMFF file, + * this will be the whole PSSH box. + */ + data: Uint8Array; + }>; +} + export default Representation;