From 2eb728afc14c78a67ee7d4fb63d957b4eac22e73 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 | 858 ++++++++++++------ src/core/decrypt/create_or_load_session.ts | 110 ++- src/core/decrypt/create_session.ts | 120 +-- src/core/decrypt/session_events_listener.ts | 65 +- src/core/decrypt/types.ts | 304 +++---- .../clean_old_loaded_sessions.test.ts | 38 +- .../utils/are_init_values_compatible.ts | 18 +- .../utils/clean_old_loaded_sessions.ts | 22 +- src/core/decrypt/utils/init_data_store.ts | 234 ----- .../utils/init_data_values_container.ts | 119 +++ src/core/decrypt/utils/is_session_usable.ts | 1 - src/core/decrypt/utils/key_id_comparison.ts | 70 ++ src/core/decrypt/utils/key_session_record.ts | 174 ++++ .../decrypt/utils/loaded_sessions_store.ts | 204 ++--- .../utils/persistent_sessions_store.ts | 208 +++-- ...ata_container.ts => serializable_bytes.ts} | 13 +- 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 | 82 +- src/manifest/representation.ts | 39 +- 23 files changed, 1551 insertions(+), 1186 deletions(-) delete mode 100644 src/core/decrypt/utils/init_data_store.ts create mode 100644 src/core/decrypt/utils/init_data_values_container.ts create mode 100644 src/core/decrypt/utils/key_id_comparison.ts create mode 100644 src/core/decrypt/utils/key_session_record.ts rename src/core/decrypt/utils/{init_data_container.ts => serializable_bytes.ts} (78%) 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 b2fc73548c..733f7cb72c 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,15 @@ import { OtherError, } from "../../errors"; import log from "../../log"; +import Manifest, { + Period, +} 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 +48,21 @@ import SessionEventsListener, { } from "./session_events_listener"; import setServerCertificate from "./set_server_certificate"; import { - IAttachedMediaKeysData, - IInitializationDataInfo, + IProtectionData, IKeySystemOption, - IKeyUpdateValue, + IMediaKeySessionStores, + MediaKeySessionLoadingType, + IProcessedProtectionData, } 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 InitDataValuesContainer from "./utils/init_data_values_container"; +import { + areAllKeyIdsContainedIn, + areKeyIdsEqual, + areSomeKeyIdsContainedIn, +} from "./utils/key_id_comparison"; +import KeySessionRecord from "./utils/key_session_record"; const { onEncrypted$ } = events; @@ -103,20 +111,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 @@ -130,6 +130,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 => { @@ -191,10 +203,10 @@ export default class ContentDecryptor extends EventEmitter { this._onFatalError(err); }); } @@ -349,85 +355,55 @@ export default class ContentDecryptor extends EventEmitter} */ private async _processInitializationData( - initializationData: IInitializationDataInfo + initializationData: IProcessedProtectionData, + 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"; @@ -449,8 +425,19 @@ export default class ContentDecryptor extends EventEmitter { switch (evt.type) { case "warning": @@ -471,96 +459,86 @@ export default class ContentDecryptor extends EventEmitter { - return ( - !evt.value.whitelistedKeyIds.some(whitelisted => - areArraysOfNumbersEqual(whitelisted, expected)) && - !evt.value.blacklistedKeyIDs.some(blacklisted => - areArraysOfNumbersEqual(blacklisted, expected)) - ); - }); - if (missingKeyIds.length > 0) { - evt.value.blacklistedKeyIDs.push(...missingKeyIds) ; - } + let linkedKeys; + if (sessionInfo.source === MediaKeySessionLoadingType.Created) { + // When the license has been fetched, there might be implicit key ids + // linked to the session depending on the `singleLicensePer` option. + linkedKeys = getFetchedLicenseKeysInfo(initializationData, + options.singleLicensePer, + evt.value.whitelistedKeyIds, + evt.value.blacklistedKeyIDs); + } else { + // When the MediaKeySession is just a cached/persisted one, we don't + // have any concept of "implicit key id". + linkedKeys = { whitelisted: evt.value.whitelistedKeyIds, + blacklisted: evt.value.blacklistedKeyIDs }; } - lastKeyUpdate.setValue(evt.value); + sessionInfo.record.associateKeyIds(linkedKeys.whitelisted); + sessionInfo.record.associateKeyIds(linkedKeys.blacklisted); + sessionInfo.keyStatuses = { whitelisted: linkedKeys.whitelisted, + blacklisted: linkedKeys.blacklisted }; - if ((evt.value.whitelistedKeyIds.length === 0 && - evt.value.blacklistedKeyIDs.length === 0) || - sessionType === "temporary" || - stores.persistentSessionsStore === null || - isSessionPersisted) + if (sessionInfo.record.getAssociatedKeyIds().length !== 0 && + sessionType === "persistent-license" && + stores.persistentSessionsStore !== null && + !isSessionPersisted) { - this.trigger("keyUpdate", evt.value); - return; + const { persistentSessionsStore } = stores; + cleanOldStoredPersistentInfo( + persistentSessionsStore, + EME_MAX_STORED_PERSISTENT_SESSION_INFORMATION - 1); + persistentSessionsStore.add(initializationData, + sessionInfo.record.getAssociatedKeyIds(), + mediaKeySession); + isSessionPersisted = true; } - const { persistentSessionsStore } = stores; - cleanOldStoredPersistentInfo( - persistentSessionsStore, - EME_MAX_STORED_PERSISTENT_SESSION_INFORMATION - 1); - persistentSessionsStore.add(initializationData, mediaKeySession); - isSessionPersisted = true; - - this.trigger("keyUpdate", evt.value); - return; + + if (initializationData.content !== undefined) { + updateDecipherability(initializationData.content.manifest, + linkedKeys.whitelisted, + linkedKeys.blacklisted); + } + + this._unlockInitDataQueue(); }, + error: (err) => { 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 warning for blacklisted session? }, }); this._canceller.signal.register(() => { sub.unsubscribe(); }); - if (sessionRes.type === "created-session") { - // `generateKeyRequest` awaits a single Uint8Array containing all - // initialization data. - const concatInitData = concat(...initializationData.values.map(i => i.data)); + if (options.singleLicensePer === undefined || + options.singleLicensePer === "init-data") + { + this._unlockInitDataQueue(); + } + + if (sessionRes.type === MediaKeySessionLoadingType.Created) { + const requestData = initializationData.values.constructRequestData(); try { await generateKeyRequest(mediaKeySession, initializationData.type, - concatInitData); + requestData); } catch (error) { throw new EncryptedMediaError("KEY_GENERATE_REQUEST_ERROR", error instanceof Error ? error.toString() : @@ -569,10 +547,113 @@ export default class ContentDecryptor extends EventEmitter x.record.isCompatibleWith(initializationData)); + + if (compatibleSessionInfo === undefined) { + return false; + } + + // 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 (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; - function onCleaningSession(evt : ICleaningOldSessionDataPayload) { - contentSessions.remove(evt.initializationData); + if (options.singleLicensePer === undefined || + 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 = areSomeKeyIdsContainedIn(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 = !areAllKeyIdsContainedIn(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) { + // TODO update decipherability to `true` if not? + 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) { @@ -583,7 +664,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]; + for (let j = 0; j < blacklistedKeyIDs.length; j++) { + if (areKeyIdsEqual(blacklistedKeyIDs[j], elt.keyId)) { + return false; + } + } + for (let j = 0; j < whitelistedKeyIds.length; j++) { + if (areKeyIdsEqual(whitelistedKeyIds[j], elt.keyId)) { + return true; + } + } + } + return representation.decipherable; + }); +} + +function blackListProtectionData( + manifest : Manifest, + initData : IProcessedProtectionData +) : 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.getFormattedValues() + .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 { /** @@ -634,21 +802,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 @@ -709,65 +862,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`. @@ -776,24 +936,170 @@ interface IReadyForContentStateDataAttached { * Initialized state (@see ContentDecryptorState). */ mediaKeysData : IAttachedMediaKeysData; + } +>; + +/** 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. */ +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 : { + /** 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; } -/** ContentDecryptor's internal data when in the `ReadyForContent` state. */ -interface IDisposeStateData { - state: ContentDecryptorState.Disposed; - data: null; +/** + * 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; } -/** ContentDecryptor's internal data when in the `Error` state. */ -interface IErrorStateData { - state: ContentDecryptorState.Error; - data: null; +/** + * Returns information on all keys - explicit or implicit - that are linked to + * a loaded license. + * + * In the RxPlayer, there is a concept of "explicit" key ids, which are key ids + * found in a license whose status can be known through the `keyStatuses` + * property from a `MediaKeySession`, and of "implicit" key ids, which are key + * ids which were expected to be in a fetched license, but apparently weren't. + * @param {Object} initializationData + * @param {string|undefined} singleLicensePer + * @param {Array.} usableKeyIds + * @param {Array.} unusableKeyIds + * @returns {Object} + */ +function getFetchedLicenseKeysInfo( + initializationData : IProcessedProtectionData, + singleLicensePer : undefined | "init-data" | "content", + usableKeyIds : Uint8Array[], + unusableKeyIds : Uint8Array[] +) : { whitelisted : Uint8Array[]; + blacklisted : Uint8Array[]; } +{ + /** + * Every key id associated with the MediaKeySession, starting with + * whitelisted ones. + */ + const associatedKeyIds = [...usableKeyIds, + ...unusableKeyIds]; + + if (singleLicensePer !== undefined && singleLicensePer !== "init-data") { + // We want to add the current key ids in the blacklist if it is + // not already there. + // + // We only do that when `singleLicensePer` is set to something + // else than the default `"init-data"` because this logic: + // 1. might result in a quality fallback, which is a v3.x.x + // breaking change if some APIs (like `singleLicensePer`) + // aren't used. + // 2. Rely on the EME spec regarding key statuses being well + // implemented on all supported devices, which we're not + // sure yet. Because in any other `singleLicensePer`, we + // need a good implementation anyway, it doesn't matter + // there. + const { keyIds: expectedKeyIds, + content } = initializationData; + if (expectedKeyIds !== undefined) { + const missingKeyIds = expectedKeyIds.filter(expected => { + return !associatedKeyIds.some(k => areKeyIdsEqual(k, expected)); + }); + if (missingKeyIds.length > 0) { + associatedKeyIds.push(...missingKeyIds) ; + } + } + + if (content !== undefined) { + // Put it in a Set to automatically filter out duplicates (by ref) + const contentKeys = new Set(); + const { manifest } = content; + for (const period of manifest.periods) { + addKeyIdsFromPeriod(contentKeys, period); + } + mergeKeyIdSetIntoArray(contentKeys, associatedKeyIds); + } + } + return { whitelisted: usableKeyIds, + /** associatedKeyIds starts with the whitelisted one. */ + blacklisted: associatedKeyIds.slice(usableKeyIds.length) }; +} + +function mergeKeyIdSetIntoArray( + set : Set, + arr : Uint8Array[] +) { + for (const kid of set.values()) { + const isFound = arr.some(k => areKeyIdsEqual(k, kid)); + if (!isFound) { + arr.push(kid); + } + } } -interface IContentSessionInfo { - /** Initialization data which triggered the creation of this session. */ - initializationData : IInitializationDataInfo; - /** Last key update event received for that session. */ - lastKeyUpdate : ISharedReference; +function addKeyIdsFromPeriod( + set : Set, + period : Period +) { + for (const adaptation of period.getAdaptations()) { + for (const representation of adaptation.representations) { + if (representation.contentProtections !== undefined && + representation.contentProtections.keyIds.length >= - 1) + { + for (const kidInf of representation.contentProtections.keyIds) { + set.add(kidInf.keyId); + } + } + } + } } diff --git a/src/core/decrypt/create_or_load_session.ts b/src/core/decrypt/create_or_load_session.ts index c8d7558107..9c4fb88bbb 100644 --- a/src/core/decrypt/create_or_load_session.ts +++ b/src/core/decrypt/create_or_load_session.ts @@ -19,47 +19,13 @@ import log from "../../log"; import { CancellationSignal } from "../../utils/task_canceller"; import createSession from "./create_session"; import { - IInitializationDataInfo, + IProcessedProtectionData, 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( - initializationData : IInitializationDataInfo, +export default async function createOrLoadSession( + initializationData : IProcessedProtectionData, 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..0059af36fb 100644 --- a/src/core/decrypt/create_session.ts +++ b/src/core/decrypt/create_session.ts @@ -21,62 +21,48 @@ import { } from "../../compat"; import log from "../../log"; import { - IInitializationDataInfo, + IProcessedProtectionData, 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 : IProcessedProtectionData, 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); } /** @@ -88,18 +74,17 @@ export default function createSession( */ function createTemporarySession( loadedSessionsStore : LoadedSessionsStore, - initData : IInitializationDataInfo + initData : IProcessedProtectionData ) : 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 @@ -108,34 +93,32 @@ function createTemporarySession( async function createAndTryToRetrievePersistentSession( loadedSessionsStore : LoadedSessionsStore, persistentSessionsStore : PersistentSessionsStore, - initData : IInitializationDataInfo + initData : IProcessedProtectionData ) : 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 fdd059a16a..cdcfa3eaf1 100644 --- a/src/core/decrypt/types.ts +++ b/src/core/decrypt/types.ts @@ -14,18 +14,19 @@ * 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 InitDataValuesContainer from "./utils/init_data_values_container"; import LoadedSessionsStore from "./utils/loaded_sessions_store"; import PersistentSessionsStore from "./utils/persistent_sessions_store"; /** Information about the encryption initialization data. */ -export interface IInitializationDataInfo { +export interface IProtectionData { /** * The initialization data type - or the format of the `data` attribute (e.g. * "cenc"). @@ -41,167 +42,50 @@ export interface IInitializationDataInfo { * just mean that there's no key id involved). */ keyIds? : Uint8Array[] | undefined; + /** 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; - }>; + values: IInitDataValue[]; } -/** 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; - }; -} - -/** - * 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. - */ -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; +/** Protection initialization data actually processed by the `ContentDecryptor`. */ +export interface IProcessedProtectionData extends Omit { + values: InitDataValuesContainer; } - /** - * 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. + * Represent the initialization data linked to a key system that can be used to + * generate the license request. */ -export interface IKeysUpdateEvent { - type: "keys-update"; - value: IKeyUpdateValue; -} - -/** Information on key ids linked to a MediaKeySession. */ -export interface IKeyUpdateValue { +export interface IInitDataValue { /** - * 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 +101,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[] | undefined; + keyIds : Uint8Array[] | undefined; /** Every initialization data for that type. */ values: Array<{ /** @@ -234,23 +118,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 +139,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 +192,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: Array; + + /** + * 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 | string; + }>; +} + +/** + * 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 +275,14 @@ export interface IPersistentSessionInfoV3 { * The initialization data associated to the systemId, wrapped in a * container to allow efficient serialization. */ - data : IInitDataContainer; + data : ByteArrayContainer | string; }>; } /** * 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 +296,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 +354,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/are_init_values_compatible.ts b/src/core/decrypt/utils/are_init_values_compatible.ts index 6d0bb6d74f..326010cf5b 100644 --- a/src/core/decrypt/utils/are_init_values_compatible.ts +++ b/src/core/decrypt/utils/are_init_values_compatible.ts @@ -15,7 +15,7 @@ */ import areArraysOfNumbersEqual from "../../../utils/are_arrays_of_numbers_equal"; -import InitDataContainer from "./init_data_container"; +import SerializableBytes from "./serializable_bytes"; /** * Returns `true` if both values are compatible initialization data, which @@ -29,10 +29,10 @@ import InitDataContainer from "./init_data_container"; export default function areInitializationValuesCompatible( stored : Array<{ systemId : string | undefined; hash : number; - data : Uint8Array | InitDataContainer; }>, + data : Uint8Array | SerializableBytes | string; }>, newElts : Array<{ systemId : string | undefined; hash : number; - data : Uint8Array | InitDataContainer; }> + data : Uint8Array | SerializableBytes | string; }> ) : boolean { return _isAInB(stored, newElts) ?? _isAInB(newElts, stored) ?? @@ -62,10 +62,10 @@ export default function areInitializationValuesCompatible( function _isAInB( a : Array<{ systemId : string | undefined; hash : number; - data : Uint8Array | InitDataContainer; }>, + data : Uint8Array | SerializableBytes | string; }>, b : Array<{ systemId : string | undefined; hash : number; - data : Uint8Array | InitDataContainer; }> + data : Uint8Array | SerializableBytes | string; }> ) : boolean | null { if (a.length === 0) { return false; @@ -89,11 +89,11 @@ function _isAInB( const aData : Uint8Array = firstAElt.data instanceof Uint8Array ? firstAElt.data : - typeof firstAElt.data === "string" ? InitDataContainer.decode(firstAElt.data) : + typeof firstAElt.data === "string" ? SerializableBytes.decode(firstAElt.data) : firstAElt.data.initData; const bData : Uint8Array = bElt.data instanceof Uint8Array ? bElt.data : - typeof bElt.data === "string" ? InitDataContainer.decode(bElt.data) : + typeof bElt.data === "string" ? SerializableBytes.decode(bElt.data) : bElt.data.initData; if (!areArraysOfNumbersEqual(aData, bData)) { return false; @@ -117,11 +117,11 @@ function _isAInB( } const aNewData : Uint8Array = aElt.data instanceof Uint8Array ? aElt.data : - typeof aElt.data === "string" ? InitDataContainer.decode(aElt.data) : + typeof aElt.data === "string" ? SerializableBytes.decode(aElt.data) : aElt.data.initData; const bNewData : Uint8Array = bNewElt.data instanceof Uint8Array ? bNewElt.data : - typeof bNewElt.data === "string" ? InitDataContainer.decode(bNewElt.data) : + typeof bNewElt.data === "string" ? SerializableBytes.decode(bNewElt.data) : bNewElt.data.initData; if (!areArraysOfNumbersEqual(aNewData, bNewData)) { return false; 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/init_data_store.ts b/src/core/decrypt/utils/init_data_store.ts deleted file mode 100644 index f43802c2af..0000000000 --- a/src/core/decrypt/utils/init_data_store.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * 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 hashBuffer from "../../../utils/hash_buffer"; -import { IInitializationDataInfo } from "../types"; -import areInitializationValuesCompatible from "./are_init_values_compatible"; - -/** - * Store a unique value associated to an initData and initDataType. - * @class InitDataStore - */ -export default class InitDataStore { - /** - * Contains every stored elements alongside the corresponding initialization - * data, in storage chronological order (from first stored to last stored). - */ - private _storage : Array<{ - /** Initialization data type. */ - type : string | undefined; - /** Every initialization data for that type. */ - values: Array<{ - /** Hex encoded system id, which identifies the key system. */ - systemId : string | undefined; - /** The initialization data itself for that type and systemId. */ - data: Uint8Array; - /** - * A hash of the `data` property, done with the `hashBuffer` util, for - * faster comparison. - */ - hash : number; - }>; - payload : T; - }>; - - /** Construct a new InitDataStore. */ - constructor() { - this._storage = []; - } - - /** - * Returns all stored value, in the order in which they have been stored. - * Note: it is possible to move a value to the end of this array by calling - * the `getAndReuse` method. - * @returns {Array} - */ - public getAll() : T[] { - return this._storage.map(item => item.payload); - } - - /** - * Returns the number of stored values. - * @returns {number} - */ - public getLength() : number { - return this._storage.length; - } - - /** - * Returns `true` if no initialization data is stored yet in this - * InitDataStore. - * Returns `false` otherwise. - * @returns {boolean} - */ - public isEmpty() : boolean { - return this._storage.length === 0; - } - - /** - * Returns the element associated with the given initData and initDataType. - * Returns `undefined` if not found. - * @param {Uint8Array} initData - * @param {string|undefined} initDataType - * @returns {*} - */ - public get(initializationData : IInitializationDataInfo) : T | undefined { - const index = this._findIndex(initializationData); - return index >= 0 ? this._storage[index].payload : - undefined; - } - - /** - * Like `get`, but also move the corresponding value at the end of the store - * (as returned by `getAll`) if found. - * This can be used for example to tell when a previously-stored value 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 {Uint8Array} initData - * @param {string|undefined} initDataType - * @returns {*} - */ - public getAndReuse( - initializationData : IInitializationDataInfo - ) : T | undefined { - const index = this._findIndex(initializationData); - if (index === -1) { - return undefined; - } - const item = this._storage.splice(index, 1)[0]; - this._storage.push(item); - return item.payload; - } - - /** - * Add to the store a value linked to the corresponding initData and - * initDataType. - * If a value was already stored linked to those, replace it. - * @param {Object} initializationData - * @param {*} payload - */ - public store( - initializationData : IInitializationDataInfo, - payload : T - ) : void { - const indexOf = this._findIndex(initializationData); - if (indexOf >= 0) { - // this._storage contains the stored value in the same order they have - // been put. So here we want to remove the previous element and re-push - // it to the end. - this._storage.splice(indexOf, 1); - } - const values = this._formatValuesForStore(initializationData.values); - this._storage.push({ type: initializationData.type, - values, - payload }); - } - - /** - * Add to the store a value linked to the corresponding initData and - * initDataType. - * If a value linked to those was already stored, do nothing and returns - * `false`. - * If not, add the value and return `true`. - * - * This can be used as a more performant version of doing both a `get` call - - * to see if a value is stored linked to that data - and then if not doing a - * store. `storeIfNone` is more performant as it will only perform hashing - * and a look-up a single time. - * @param {Object} initializationData - * @param {*} payload - * @returns {boolean} - */ - public storeIfNone( - initializationData : IInitializationDataInfo, - payload : T - ) : boolean { - const indexOf = this._findIndex(initializationData); - if (indexOf >= 0) { - return false; - } - const values = this._formatValuesForStore(initializationData.values); - this._storage.push({ type: initializationData.type, - values, - payload }); - return true; - } - - /** - * Remove an initDataType and initData combination from this store. - * Returns the associated value if it has been found, `undefined` otherwise. - * @param {Uint8Array} initData - * @param {string|undefined} initDataType - * @returns {*} - */ - public remove(initializationData : IInitializationDataInfo) : T | undefined { - const indexOf = this._findIndex(initializationData); - if (indexOf === -1) { - return undefined; - } - return this._storage.splice(indexOf, 1)[0].payload; - } - - /** - * Find the index of the corresponding initialization data in `this._storage`. - * Returns `-1` if not found. - * @param {Object} initializationData - * @returns {boolean} - */ - private _findIndex( - initializationData : IInitializationDataInfo - ) : number { - const formattedVals = this._formatValuesForStore(initializationData.values); - - - // Begin by the last element as we usually re-encounter the last stored - // initData sooner than the first one. - for (let i = this._storage.length - 1; i >= 0; i--) { - const stored = this._storage[i]; - if (stored.type === initializationData.type) { - if (areInitializationValuesCompatible(stored.values, formattedVals)) { - return i; - } - } - } - return -1; - } - - /** - * Format given initializationData's values so they are ready to be stored: - * - sort them by systemId, so they are faster to compare - * - add hash for each initialization data encountered. - * @param {Array.} initialValues - * @returns {Array.} - */ - private _formatValuesForStore( - 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) })); - } -} diff --git a/src/core/decrypt/utils/init_data_values_container.ts b/src/core/decrypt/utils/init_data_values_container.ts new file mode 100644 index 0000000000..3e355e158d --- /dev/null +++ b/src/core/decrypt/utils/init_data_values_container.ts @@ -0,0 +1,119 @@ +/** + * 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 { concat } from "../../../utils/byte_parsing"; +import hashBuffer from "../../../utils/hash_buffer"; +import { IInitDataValue } from "../types"; +import areInitializationValuesCompatible from "./are_init_values_compatible"; + +/** + * Wrap initialization data values and reformat it so it becomes easier to check + * compatibility with other `InitDataValuesContainer`. + * @class InitDataValuesContainer + */ +export default class InitDataValuesContainer { + private readonly _innerValues : IInitDataValue[]; + private _lazyFormattedValues : IFormattedInitDataValue[] | null; + + /** + * Construct a new `InitDataValuesContainer`. + * Note that the data is not formatted right away. + * It is only really formatted lazily the first time we need it. + * + * @param {Array.} initDataValues + */ + constructor(initDataValues : IInitDataValue[]) { + this._innerValues = initDataValues; + this._lazyFormattedValues = null; + } + + /** + * Construct data that should be given to the `generateRequest` EME API. + * @returns {Uint8Array} + */ + public constructRequestData() : Uint8Array { + // `generateKeyRequest` awaits a single Uint8Array containing all + // initialization data. + return concat(...this._innerValues.map(i => i.data)); + } + + /** + * Returns `true` if the given `InitDataValuesContainer` seems to be + * "compatible" with the one stored in this instance. + * Returns `false` if not. + * + * By "compatible" we mean that it will generate the same key request. + * @param {InitDataValuesContainer | Object} initDataValues + * @returns {boolean} + */ + public isCompatibleWith( + initDataValues : InitDataValuesContainer | IFormattedInitDataValue[] + ) : boolean { + const formatted = initDataValues instanceof InitDataValuesContainer ? + initDataValues.getFormattedValues() : + initDataValues; + return areInitializationValuesCompatible(this.getFormattedValues(), formatted); + } + + /** + * Return the stored initialization data values, with added niceties: + * - they are sorted always the same way for similar + * `InitDataValuesContainer` + * - each value is associated to its hash, which is always done with the + * same hashing function than for all other InitDataValuesContainer). + * + * The main point being to be able to compare much faster multiple + * `InitDataValuesContainer`, though that data can also be used in any + * other way. + * @returns {Array.} + */ + public getFormattedValues() : IFormattedInitDataValue[] { + if (this._lazyFormattedValues === null) { + this._lazyFormattedValues = formatInitDataValues(this._innerValues); + } + return this._lazyFormattedValues; + } +} + +/** + * 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.} + */ +function formatInitDataValues( + initialValues : IInitDataValue[] +) : IFormattedInitDataValue[] { + 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 compare to others. + */ +export interface IFormattedInitDataValue { + systemId : string | undefined; + hash : number; + data : Uint8Array; +} 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..d1120a39e3 --- /dev/null +++ b/src/core/decrypt/utils/key_id_comparison.ts @@ -0,0 +1,70 @@ +/** + * 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 both given key id appear to be equal. + * @param {Uint8Array} keyId1 + * @param {Uint8Array} keyId2 + * @returns {boolean} + */ +export function areKeyIdsEqual( + keyId1 : Uint8Array, + keyId2 : Uint8Array +) : boolean { + return keyId1 === keyId2 || areArraysOfNumbersEqual(keyId1, keyId2); +} + +/** + * Returns `true` if all key ids in `wantedKeyIds` are present in the + * `keyIdsArr` array. + * @param {Array.} wantedKeyIds + * @param {Array.} keyIdsArr + * @returns {boolean} + */ +export function areAllKeyIdsContainedIn( + wantedKeyIds : Uint8Array[], + keyIdsArr : Uint8Array[] +) : boolean { + for (const keyId of wantedKeyIds) { + const found = keyIdsArr.some(k => areKeyIdsEqual(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 areSomeKeyIdsContainedIn( + wantedKeyIds : Uint8Array[], + keyIdsArr : Uint8Array[] +) : boolean { + for (const keyId of wantedKeyIds) { + const found = keyIdsArr.some(k => areKeyIdsEqual(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..78b7af7bae --- /dev/null +++ b/src/core/decrypt/utils/key_session_record.ts @@ -0,0 +1,174 @@ +/** + * 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 { IProcessedProtectionData } from "../types"; +import { + areKeyIdsEqual, + areAllKeyIdsContainedIn, +} from "./key_id_comparison"; + +/** + * 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 : IProcessedProtectionData; + private _keyIds : Uint8Array[] | null; + + /** + * Create a new `KeySessionRecord`, linked to its corresponding initialization + * data, + * @param {Object} initializationData + */ + constructor(initializationData : IProcessedProtectionData) { + 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[] | IterableIterator + ) : void { + if (this._keyIds === null) { + this._keyIds = []; + } + for (const keyId of keyIds) { + if (!this.isAssociatedWithKeyId(keyId)) { + this._keyIds.push(keyId); + } + } + } + + /** + * @param {Uint8Array} keyId + * @returns {boolean} + */ + public isAssociatedWithKeyId(keyId : Uint8Array) : boolean { + if (this._keyIds === null) { + return false; + } + for (const storedKeyId of this._keyIds) { + if (areKeyIdsEqual(storedKeyId, keyId)) { + return true; + } + } + return false; + } + + /** + * @returns {Array.} + */ + public getAssociatedKeyIds() : Uint8Array[] { + if (this._keyIds === null) { + return []; + } + return this._keyIds; + } + + /** + * 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 : IProcessedProtectionData + ) : boolean { + const { keyIds } = initializationData; + if (keyIds !== undefined) { + if (this._keyIds !== null && areAllKeyIdsContainedIn(keyIds, this._keyIds)) { + return true; + } + if (this._initializationData.keyIds !== undefined) { + return areAllKeyIdsContainedIn(keyIds, this._initializationData.keyIds); + } + } + return this._checkInitializationDataCompatibility(initializationData); + } + + private _checkInitializationDataCompatibility( + initializationData : IProcessedProtectionData + ) : boolean { + if (initializationData.keyIds !== undefined && + this._initializationData.keyIds !== undefined) + { + return areAllKeyIdsContainedIn(initializationData.keyIds, + this._initializationData.keyIds); + } + + if (this._initializationData.type !== initializationData.type) { + return false; + } + + return this._initializationData.values + .isCompatibleWith(initializationData.values); + } +} diff --git a/src/core/decrypt/utils/loaded_sessions_store.ts b/src/core/decrypt/utils/loaded_sessions_store.ts index 42b6ef4f87..fd5d99e9ff 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 { IProcessedProtectionData } from "../types"; +import KeySessionRecord from "./key_session_record"; /** * Create and store MediaKeySessions linked to a single MediaKeys @@ -65,7 +44,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 @@ -74,89 +53,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 : IProcessedProtectionData, 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) => { @@ -166,21 +86,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 reuse( + initializationData : IProcessedProtectionData + ) : 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( - initializationData : IInitializationDataInfo + 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"); @@ -195,7 +149,7 @@ export default class LoadedSessionsStore { * @returns {number} */ public getLength() : number { - return this._storage.getLength(); + return this._storage.length; } /** @@ -204,27 +158,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..c01640538a 100644 --- a/src/core/decrypt/utils/persistent_sessions_store.ts +++ b/src/core/decrypt/utils/persistent_sessions_store.ts @@ -18,20 +18,19 @@ import { ICustomMediaKeySession } from "../../../compat"; import log from "../../../log"; import areArraysOfNumbersEqual from "../../../utils/are_arrays_of_numbers_equal"; import { assertInterface } from "../../../utils/assert"; -import { - base64ToBytes, - bytesToBase64, -} from "../../../utils/base64"; -import { concat } from "../../../utils/byte_parsing"; +import { bytesToBase64 } from "../../../utils/base64"; import hashBuffer from "../../../utils/hash_buffer"; import isNonEmptyString from "../../../utils/is_non_empty_string"; import isNullOrUndefined from "../../../utils/is_null_or_undefined"; import { - IInitializationDataInfo, + IProcessedProtectionData, IPersistentSessionInfo, IPersistentSessionStorage, } from "../types"; import areInitializationValuesCompatible from "./are_init_values_compatible"; +import { IFormattedInitDataValue } from "./init_data_values_container"; +import { areKeyIdsEqual } from "./key_id_comparison"; +import SerializableBytes from "./serializable_bytes"; /** * Throw if the given storage does not respect the right interface. @@ -43,42 +42,6 @@ function checkStorage(storage : IPersistentSessionStorage) : void { "licenseStorage"); } -/** Wrap initialization data and allow serialization of it into base64. */ -class InitDataContainer { - /** The initData itself. */ - public initData : Uint8Array; - - /** - * Create a new container, wrapping the initialization data given and allowing - * linearization into base64. - * @param {Uint8Array} - */ - constructor(initData : Uint8Array) { - this.initData = initData; - } - - /** - * Convert it to base64. - * `toJSON` is specially interpreted by JavaScript engines to be able to rely - * on it when calling `JSON.stringify` on it or any of its parent objects: - * https://tc39.es/ecma262/#sec-serializejsonproperty - * @returns {string} - */ - toJSON() : string { - return bytesToBase64(this.initData); - } - - /** - * Decode a base64 sequence representing an initialization data back to an - * Uint8Array. - * @param {string} - * @returns {Uint8Array} - */ - static decode(base64 : string) : Uint8Array { - return base64ToBytes(base64); - } -} - /** * Set representing persisted licenses. Depends on a simple local- * storage implementation with a `save`/`load` synchronous interface @@ -101,10 +64,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,11 +94,11 @@ 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} */ - public get(initData : IInitializationDataInfo) : IPersistentSessionInfo | null { + public get(initData : IProcessedProtectionData) : IPersistentSessionInfo | null { const index = this._getIndex(initData); return index === -1 ? null : this._entries[index]; @@ -151,7 +115,9 @@ export default class PersistentSessionsStore { * @param {string|undefined} initDataType * @returns {*} */ - public getAndReuse(initData : IInitializationDataInfo) : IPersistentSessionInfo | null { + public getAndReuse( + initData : IProcessedProtectionData + ) : IPersistentSessionInfo | null { const index = this._getIndex(initData); if (index === -1) { return null; @@ -168,7 +134,8 @@ export default class PersistentSessionsStore { * @param {MediaKeySession} session */ public add( - initData : IInitializationDataInfo, + initData : IProcessedProtectionData, + keyIds : Uint8Array[] | undefined, session : MediaKeySession|ICustomMediaKeySession ) : void { if (isNullOrUndefined(session) || !isNonEmptyString(session.sessionId)) { @@ -180,26 +147,41 @@ 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); - this._entries.push({ version: 3, - sessionId, - values: this._formatValuesForStore(initData.values), - initDataType: initData.type }); + const storedValues = prepareValuesForStore(initData.values.getFormattedValues()); + if (keyIds === undefined) { + this._entries.push({ version: 3, + sessionId, + values: storedValues, + initDataType: initData.type }); + } else { + this._entries.push({ version: 4, + sessionId, + keyIds: keyIds.map((k) => new SerializableBytes(k)), + values: storedValues, + initDataType: initData.type }); + } this._save(); } /** - * 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,34 +219,70 @@ 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 { - const formatted = this._formatValuesForStore(initData.values); - + private _getIndex(initData : IProcessedProtectionData) : number { // Older versions of the format include a concatenation of all // initialization data and its hash. - const concatInitData = concat(...initData.values.map(i => i.data)); - const concatInitDataHash = hashBuffer(concatInitData); + // This is only computed lazily, the first time it is needed. + let lazyConcatenatedData : null | { initData : Uint8Array; + initDataHash : number; } = null; + function getConcatenatedInitDataInfo() { + if (lazyConcatenatedData === null) { + const concatInitData = initData.values.constructRequestData(); + lazyConcatenatedData = { initData: concatInitData, + initDataHash: hashBuffer(concatInitData) }; + } + return lazyConcatenatedData; + } for (let i = 0; i < this._entries.length; i++) { const entry = this._entries[i]; if (entry.initDataType === initData.type) { switch (entry.version) { + case 4: + if (initData.keyIds !== undefined) { + const foundCompatible = initData.keyIds.every(keyId => { + const keyIdB64 = bytesToBase64(keyId); + for (const entryKid of entry.keyIds) { + if (typeof entryKid === "string") { + if (keyIdB64 === entryKid) { + return true; + } + } else if (areKeyIdsEqual(entryKid.initData, + keyId)) + { + return true; + } + } + return false; + }); + if (foundCompatible) { + return i; + } + } else { + const formatted = initData.values.getFormattedValues(); + if (areInitializationValuesCompatible(formatted, entry.values)) { + return i; + } + } + break; case 3: + const formatted = initData.values.getFormattedValues(); if (areInitializationValuesCompatible(formatted, entry.values)) { return i; } break; - case 2: - if (entry.initDataHash === concatInitDataHash) { + case 2: { + const { initData: concatInitData, + initDataHash: concatHash } = getConcatenatedInitDataInfo(); + if (entry.initDataHash === concatHash) { try { const decodedInitData : Uint8Array = typeof entry.initData === "string" ? - InitDataContainer.decode(entry.initData) : + SerializableBytes.decode(entry.initData) : entry.initData.initData; if (areArraysOfNumbersEqual(decodedInitData, concatInitData)) { return i; @@ -274,9 +292,12 @@ export default class PersistentSessionsStore { } } break; + } - case 1: - if (entry.initDataHash === concatInitDataHash) { + case 1: { + const { initData: concatInitData, + initDataHash: concatHash } = getConcatenatedInitDataInfo(); + if (entry.initDataHash === concatHash) { if (typeof entry.initData.length === "undefined") { // If length is undefined, it has been linearized. We could still // convert it back to an Uint8Array but this would necessitate some @@ -288,11 +309,14 @@ export default class PersistentSessionsStore { } } break; + } - default: - if (entry.initData === concatInitDataHash) { + default: { + const { initDataHash: concatHash } = getConcatenatedInitDataInfo(); + if (entry.initData === concatHash) { return i; } + } } } } @@ -309,28 +333,22 @@ export default class PersistentSessionsStore { log.warn("DRM-PSS: Could not save licenses in localStorage"); } } +} - /** - * Format given initializationData's values so they are ready to be stored: - * - sort them by systemId, so they are faster to compare - * - add hash for each initialization data encountered. - * @param {Array.} initialValues - * @returns {Array.} - */ - private _formatValuesForStore( - initialValues : Array<{ systemId : string | undefined; - data : Uint8Array; }> - ) : Array<{ systemId : string | undefined; - hash : number; - data : InitDataContainer; }> { - 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 : new InitDataContainer(data), - hash : hashBuffer(data) })); - } +/** + * Format given initializationData's values so they are ready to be stored: + * - sort them by systemId, so they are faster to compare + * - add hash for each initialization data encountered. + * @param {Array.} initialValues + * @returns {Array.} + */ +function prepareValuesForStore( + initialValues : IFormattedInitDataValue[] +) : Array<{ systemId : string | undefined; + hash : number; + data : SerializableBytes; }> { + return initialValues + .map(({ systemId, data, hash }) => ({ systemId, + hash, + data : new SerializableBytes(data) })); } diff --git a/src/core/decrypt/utils/init_data_container.ts b/src/core/decrypt/utils/serializable_bytes.ts similarity index 78% rename from src/core/decrypt/utils/init_data_container.ts rename to src/core/decrypt/utils/serializable_bytes.ts index 84ee1ab9a4..1462456399 100644 --- a/src/core/decrypt/utils/init_data_container.ts +++ b/src/core/decrypt/utils/serializable_bytes.ts @@ -19,14 +19,17 @@ import { bytesToBase64, } from "../../../utils/base64"; -/** Wrap initialization data and allow serialization of it into base64. */ -export default class InitDataContainer { - /** The initData itself. */ +/** Wrap byte-based data and allow serialization of it into base64. */ +export default class SerializableBytes { + /** + * The data itself. Named `initData` for legacy reasons (cannot be changed + * because it has an impact on saved persistent session information. + */ public initData : Uint8Array; /** - * Create a new container, wrapping the initialization data given and allowing - * linearization into base64. + * Create a new `SerializableBytes`, wrapping the initialization data + * given and allowing serialization into base64. * @param {Uint8Array} */ constructor(initData : Uint8Array) { diff --git a/src/core/init/initialize_media_source.ts b/src/core/init/initialize_media_source.ts index 4005fe58a0..37cc7731c4 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"; @@ -272,20 +271,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 38f3436650..ee6cfb7e4b 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); } @@ -795,14 +730,9 @@ function updateDeciperability( isDecipherable : (rep : Representation) => boolean | undefined ) : IDecipherabilityUpdateElement[] { const updates : IDecipherabilityUpdateElement[] = []; - for (let i = 0; i < manifest.periods.length; i++) { - const period = manifest.periods[i]; - const adaptations = period.getAdaptations(); - for (let j = 0; j < adaptations.length; j++) { - const adaptation = adaptations[j]; - const representations = adaptation.representations; - for (let k = 0; k < representations.length; k++) { - const representation = representations[k]; + for (const period of manifest.periods) { + for (const adaptation of period.getAdaptations()) { + for (const representation of adaptation.representations) { const result = isDecipherable(representation); if (result !== representation.decipherable) { updates.push({ manifest, period, adaptation, representation }); diff --git a/src/manifest/representation.ts b/src/manifest/representation.ts index 438bde329e..07a2bd38be 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;