UNPKG

rx-player

Version:
1,059 lines (964 loc) 31.9 kB
import type { IMediaKeySession, IMediaKeySystemAccess, IMediaKeys, } from "../../../compat/browser_compatibility_types"; import EnvDetector from "../../../compat/env_detector"; import { getBoxOffsets } from "../../../parsers/containers/isobmff"; import arrayIncludes from "../../../utils/array_includes"; import assert from "../../../utils/assert"; import { base64ToBytes, bytesToBase64 } from "../../../utils/base64"; import { be4toi, le2toi, toUint8Array } from "../../../utils/byte_parsing"; import createUuid from "../../../utils/create_uuid"; import EventEmitter from "../../../utils/event_emitter"; import isNullOrUndefined from "../../../utils/is_null_or_undefined"; import noop from "../../../utils/noop"; import type { IReadOnlySharedReference } from "../../../utils/reference"; import SharedReference from "../../../utils/reference"; import sliceUint8array from "../../../utils/slice_uint8array"; import { bytesToHex, guidToUuid, hexToBytes, strToUtf8, utf16LEToStr, utf8ToStr, } from "../../../utils/string_parsing"; export interface IRequestMediaKeySystemAccessConfig { /** * For the given arguments of the `navigator.requestMediaKeySystemAccess` * API, returns the resulting `MediaKeySystemConfiguration`, or `null` if * that configuration should be anounced as not supported. * * If not set, the first configuration will be anounced as supported for a * supported key system. * * @param {string} keySystem * @param {Array.<Object>} configs * @returns {Object} config */ getMediaKeySystemConfiguration?: | (( keySystem: string, configs: MediaKeySystemConfiguration[], ) => MediaKeySystemConfiguration | null) | undefined; /** * For the given key system, returns `true` if it should be anounced as * supported or `false` if it should be anounced as unsupported. * * If not set, all keySystems are supported. * @param {string} keySystem * @returns {boolean} */ isKeySystemSupported: ((keySystem: string) => boolean) | undefined; } /** * Return a configured re-implementation of the EME * `navigator.requestMediaKeySystemAccess` API. * @param {Object|undefined} [config] * @returns {Function} */ export function createRequestMediaKeySystemAccess( config?: IRequestMediaKeySystemAccessConfig | undefined, ) { /** * Re-implementation of the EME `navigator.requestMediaKeySystemAccess` API. * @param {string} keySystem * @param {Array.<Object>} supportedConfigurations * @returns {Promise.<Object>} */ return function requestMediaKeySystemAccess( keySystem: string, supportedConfigurations: MediaKeySystemConfiguration[], ): Promise<DummyMediaKeySystemAccess> { if (keySystem === "") { return Promise.reject( new TypeError("`requestMediaKeySystemAccess` error: empty string"), ); } if (supportedConfigurations.length === 0) { return Promise.reject( new TypeError("`requestMediaKeySystemAccess` error: no given configuration."), ); } if ( typeof config?.isKeySystemSupported === "function" && !config.isKeySystemSupported(keySystem) ) { const error = new Error(`"${keySystem}" is not a supported keySystem`); error.name = "NotSupportedError"; return Promise.reject(error); } let supportedConfiguration: MediaKeySystemConfiguration | null = supportedConfigurations[0] ?? null; if (typeof config?.getMediaKeySystemConfiguration === "function") { supportedConfiguration = config.getMediaKeySystemConfiguration( keySystem, supportedConfigurations, ); } if (supportedConfiguration === null) { const error = new Error( "`requestMediaKeySystemAccess` error: No configuration supported.", ); error.name = "NotSupportedError"; return Promise.reject(error); } // check some mandatory configuration state // Clone so following setter don't update the source object supportedConfiguration = { ...supportedConfiguration }; supportedConfiguration.persistentState = isNullOrUndefined(supportedConfiguration.persistentState) || supportedConfiguration.persistentState === "optional" ? "not-allowed" : supportedConfiguration.persistentState; supportedConfiguration.distinctiveIdentifier = isNullOrUndefined(supportedConfiguration.distinctiveIdentifier) || supportedConfiguration.distinctiveIdentifier === "optional" ? "not-allowed" : supportedConfiguration.distinctiveIdentifier; return Promise.resolve( new DummyMediaKeySystemAccess(keySystem, supportedConfiguration), ); }; } /** * Re-implementation of the EME `MediaKeySystemAccess` Object. * @class DummyMediaKeySystemAccess */ export class DummyMediaKeySystemAccess implements IMediaKeySystemAccess { public readonly keySystem: string; private _configuration: MediaKeySystemConfiguration; /** * @param {string} keySystem * @param {Object} configuration */ constructor(keySystem: string, configuration: MediaKeySystemConfiguration) { this.keySystem = keySystem; this._configuration = configuration; } /** * @returns {Object} */ public getConfiguration(): MediaKeySystemConfiguration { return this._configuration; } /** * @returns {Promise.<Object>} */ public createMediaKeys(): Promise<DummyMediaKeys> { // TODO persistent-license return Promise.resolve( new DummyMediaKeys(this.keySystem, ["temporary" /* , "persistent-license" */]), ); } } /** * Re-implementation of the EME `MediaKeys` Object. * @class DummyMediaKeys */ export class DummyMediaKeys implements IMediaKeys { private _keySystem: string; private _sessionTypes: MediaKeySessionType[]; private _serverCertificateRef: SharedReference<Uint8Array | null>; public dummySessions: DummyMediaKeySession[]; public onDummySessionKeyUpdates: (() => void) | null; constructor(keySystem: string, sessionTypes: MediaKeySessionType[]) { this._keySystem = keySystem; this._sessionTypes = sessionTypes; this._serverCertificateRef = new SharedReference<Uint8Array | null>(null); this.dummySessions = []; this.onDummySessionKeyUpdates = null; } /** * @param {string} sessionType * @returns {Object} */ public createSession( sessionType: MediaKeySessionType = "temporary", ): DummyMediaKeySession { if (!arrayIncludes(this._sessionTypes, sessionType)) { const error = new Error( `\`createSession\`: ${sessionType} sessionType not supported`, ); error.name = "NotSupportedError"; throw error; } // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const newSession = new DummyMediaKeySession({ keySystem: this._keySystem, sessionType, serverCertificateRef: this._serverCertificateRef, callbacks: { onClosed() { const index = self.dummySessions.indexOf(newSession); if (index >= 0) { self.dummySessions.splice(index, 1); } }, onKeysUpdate() { self.onDummySessionKeyUpdates?.(); }, }, }); this.dummySessions.push(newSession); return newSession; } /** * @param {BufferSource} serverCertificate * @returns {Promise.<boolean>} */ setServerCertificate(serverCertificate: BufferSource): Promise<boolean> { if (serverCertificate.byteLength === 0) { throw new TypeError( "Cannot set `serverCertificate`: an empty certificate was given", ); } const clonedServerCertificate = toUint8Array(serverCertificate).slice(); this._serverCertificateRef.setValue(clonedServerCertificate); return Promise.resolve(true); } } interface IDrmChallenge { certificate: string | null; persistent: boolean; keyIds: string[]; } interface IDrmLicense { type: "license"; persistent: boolean; expiration?: number; keys: Record< /** Key id, as lower case hex values */ string, { /** * "Restriction level" for this key id, a higher number meaning more * restrictive. */ policyLevel: number; } >; } /** * Re-implementation of the EME `MediaKeySession` Object. * @class DummyMediaKeySession */ export class DummyMediaKeySession extends EventEmitter<MediaKeySessionEventMap> implements IMediaKeySession { public readonly closed: Promise<MediaKeySessionClosedReason>; public readonly keyStatuses: DummyMediaKeyStatusMap; public expiration: number; public sessionId: string; private _sessionType: MediaKeySessionType; private _unitialized: boolean; private _callable: boolean; private _closing: boolean; private _closed: boolean; private _keySystem: string; private _onSessionClosed: () => void; private _serverCertificateRef: IReadOnlySharedReference<Uint8Array | null>; private _callbacks: IDummyMediaKeySessionCallbacks; private _currentPolicyLevel: number; /** * @param {Object} args */ constructor({ keySystem, sessionType, serverCertificateRef, callbacks, }: { keySystem: string; sessionType: MediaKeySessionType; serverCertificateRef: IReadOnlySharedReference<Uint8Array | null>; callbacks: IDummyMediaKeySessionCallbacks; }) { super(); this._callbacks = callbacks; this._onSessionClosed = noop; // Just here to make TypeScript happy this.closed = new Promise((resolve) => { this._onSessionClosed = () => { this.removeEventListener(); try { this._callbacks.onClosed(); } catch { // we don't care } resolve("closed-by-application"); }; }); this._currentPolicyLevel = 100; this.expiration = NaN; this.keyStatuses = new DummyMediaKeyStatusMap(keySystem); this.sessionId = ""; this._serverCertificateRef = serverCertificateRef; this._keySystem = keySystem; this._sessionType = sessionType; this._unitialized = true; this._callable = false; this._closing = false; this._closed = false; } /** * @returns {Promise} */ public close(): Promise<void> { // 1. If this object's closing or closed value is true, return a resolved promise. if (this._closing || this._closed) { return Promise.resolve(); } // 2. If this object's callable value is false, return a promise rejected with an InvalidStateError. if (!this._callable) { const err = new Error("Cannot call `close` at this time"); err.name = "InvalidStateError"; return Promise.reject(err); } // 4. Set this object's closing or closed value to true. this._closing = true; this.keyStatuses.clear(); return new Promise((resolve) => { setTimeout(() => { try { this._callbacks.onKeysUpdate(); } catch { // We don't care } try { this.trigger("keystatuseschange", new Event("keystatuseschange")); } catch { // We don't care } this.expiration = NaN; this._closed = true; this._onSessionClosed(); resolve(); }, 0); }); } /** * @param {string} initDataType * @param {BufferSource} initData * @returns {Promise} */ public generateRequest(initDataType: string, initData: BufferSource): Promise<void> { // 1. If this object's closing or closed value is true, return a promise // rejected with an InvalidStateError. if (this._closing || this._closed) { const err = new Error("Cannot call `generateRequest`: closing session"); err.name = "InvalidStateError"; return Promise.reject(err); } // 2. If this object's uninitialized value is false, return a promise // rejected with an InvalidStateError. if (!this._unitialized) { const err = new Error("Cannot call `generateRequest`: already initialized"); err.name = "InvalidStateError"; return Promise.reject(err); } // 3. Let this object's uninitialized value be false. this._unitialized = false; // 4. If initDataType is the empty string, return a promise rejected with a // newly created TypeError. if (typeof initDataType !== "string" || initDataType === "") { return Promise.reject( new TypeError("Invalid `generateRequest` call: empty initDataType"), ); } // 5. If initData is an empty array, return a promise rejected with a newly // created TypeError. let initDataU8; if (initData instanceof ArrayBuffer) { initDataU8 = new Uint8Array(initData); } else if (initData instanceof Uint8Array) { initDataU8 = initData; } else { initDataU8 = new Uint8Array(initData.buffer); } if (initDataU8.byteLength === 0) { return Promise.reject( new TypeError("Invalid `generateRequest` call: empty initData"), ); } // 6. If the Key System implementation represented by this object's cdm // implementation value does not support initDataType as an Initialization // Data Type, return a promise rejected with a NotSupportedError. String // comparison is case-sensitive. if (initDataType !== "cenc") { const err = new Error( `Cannot call \`generateRequest\`: unsupported initDataType "${initDataType}"`, ); err.name = "NotSupportedError"; return Promise.reject(err); } // 7. Let init data be a copy of the contents of the initData parameter. const clonedInitData = initDataU8.slice(); const psshs = splitPsshBoxes(clonedInitData); let keyIds: Uint8Array[] | null = null; for (const pssh of psshs) { try { const psshInfo = getKeyIdsFromPssh(pssh, 0); if (psshInfo !== null) { switch (psshInfo.systemId) { case undefined: if (psshInfo.kids.length === 0) { keyIds = psshInfo.kids; } break; case "PlayReady": if (this._keySystem.indexOf("playready") >= 0) { keyIds = psshInfo.kids; } break; case "Widevine": if (this._keySystem.indexOf("widevine") >= 0) { keyIds = psshInfo.kids; } break; case "Nagra": if (this._keySystem.indexOf("nagra") >= 0) { keyIds = psshInfo.kids; } break; } } } catch { /* noop */ } } if (keyIds === null || keyIds.length === 0) { throw new TypeError("No key id found in initialization data"); } const kids = keyIds.map((k) => bytesToHex(k)); this.sessionId = createUuid(); this._callable = true; setTimeout(() => { const certificateBase = this._serverCertificateRef.getValue(); const certificate = certificateBase === null ? null : bytesToBase64(certificateBase); const message: IDrmChallenge = { certificate, persistent: this._sessionType === "persistent-license", keyIds: kids, }; this.trigger( "message", new MediaKeyMessageEvent("message", { messageType: "license-request", message: strToUtf8(JSON.stringify(message)).buffer, }), ); }, 0); return Promise.resolve(); } /** * @param {string} sessionId * @returns {Promise.<boolean>} */ public load(sessionId: string): Promise<boolean> { // 1. If this object's closing or closed value is true, return a promise // rejected with an InvalidStateError. if (this._closing || this._closed) { const err = new Error("Cannot call `load`: closing session"); err.name = "InvalidStateError"; return Promise.reject(err); } // 2. If this object's uninitialized value is false, return a promise // rejected with an InvalidStateError. if (!this._unitialized) { const err = new Error("Cannot call `load`: already initialized"); err.name = "InvalidStateError"; return Promise.reject(err); } // 3. Let this object's uninitialized value be false. this._unitialized = false; // 4. If sessionId is the empty string, return a promise rejected with a // newly created TypeError. if (typeof sessionId !== "string" || sessionId === "") { return Promise.reject(new TypeError("Invalid `load` call: empty sessionId")); } // TODO persistent license return Promise.reject(new TypeError("Persistent license not implemented yet.")); } /** * @returns {Promise} */ public remove(): Promise<void> { // 1. If this object's closing or closed value is true, return a promise // rejected with an InvalidStateError. if (this._closing || this._closed) { const err = new Error("Cannot call `remove`: closing session"); err.name = "InvalidStateError"; return Promise.reject(err); } // 2. If this object's callable value is false, return a promise rejected // with an InvalidStateError. if (!this._callable) { const err = new Error("Cannot call `remove` at this time"); err.name = "InvalidStateError"; return Promise.reject(err); } // Run the Update Key Statuses algorithm on the session, providing all key // ID(s) in the session along with the "released" MediaKeyStatus value for // each. const keymap = this.keyStatuses.getInnerMap(); keymap.forEach(({ policyLevel }, key) => { keymap.set(key, { status: "released", policyLevel }); }); if (this.keyStatuses.size > 0) { try { this._callbacks.onKeysUpdate(); } catch { // We don't care } setTimeout(() => { this.trigger("keystatuseschange", new Event("keystatuseschange")); }, 0); } this.expiration = NaN; return new Promise((resolve) => { resolve(); }); } /** * @param {BufferSource} response * @returns {Promise} */ public update(response: BufferSource): Promise<void> { // 1. If this object's closing or closed value is true, return a promise // rejected with an InvalidStateError. if (this._closing || this._closed) { const err = new Error("Cannot call `update`: closing session"); err.name = "InvalidStateError"; return Promise.reject(err); } // 2. If this object's callable value is false, return a promise // rejected with an InvalidStateError. if (!this._callable) { const err = new Error("Cannot call `update` at this time"); err.name = "InvalidStateError"; return Promise.reject(err); } // 3. If response is an empty array, return a promise rejected with a // newly created TypeError. if (response.byteLength === 0) { return Promise.reject(new TypeError("Invalid `update` call: empty response")); } const responseU8 = toUint8Array(response); const parsed = utf8ToStr(responseU8); let hasUpdatedKeys = false; try { const parsedObj = JSON.parse(parsed) as IDrmLicense; for (const key of Object.keys(parsedObj.keys)) { const { policyLevel } = parsedObj.keys[key]; if (policyLevel > this._currentPolicyLevel) { this.keyStatuses.set(key, "output-restricted", policyLevel); } else { this.keyStatuses.set(key, "usable", policyLevel); } hasUpdatedKeys = true; } if (parsedObj.expiration !== undefined) { this.expiration = parsedObj.expiration; } } catch (err) { throw new TypeError(err instanceof Error ? err.message : "Invalid message"); } if (hasUpdatedKeys) { setTimeout(() => { try { this._callbacks.onKeysUpdate(); } catch { // We don't care } this.trigger("keystatuseschange", new Event("keystatuseschange")); }, 0); } return Promise.resolve(); } public getPolicyLevel(): number { return this._currentPolicyLevel; } public updatePolicyLevel(newLevel: number) { this._currentPolicyLevel = newLevel; let hasUpdatedKeys = false; try { const keymap = this.keyStatuses.getInnerMap(); keymap.forEach(({ status, policyLevel }, key) => { if (policyLevel > newLevel) { if (status !== "output-restricted") { keymap.set(key, { status: "output-restricted", policyLevel }); hasUpdatedKeys = true; } } else if (status === "output-restricted") { keymap.set(key, { status: "usable", policyLevel }); hasUpdatedKeys = true; } }); } catch { // we don't care } if (hasUpdatedKeys) { setTimeout(() => { try { this._callbacks.onKeysUpdate(); } catch { // We don't care } this.trigger("keystatuseschange", new Event("keystatuseschange")); }, 0); } } } interface IDummyMediaKeySessionCallbacks { onClosed: () => void; onKeysUpdate: () => void; } /** * Re-implementation of the `MediaKeyStatusMap` where insertion is possible. * * Used to mock the corresponding EME API. * @class DummyMediaKeyStatusMap */ export class DummyMediaKeyStatusMap implements MediaKeyStatusMap { /** * Inner Map corresponding to what's that fake `MediaKeyStatusMap` maintains. * Key id are actually stored as strings as hex representation of the * underlying bytes. */ private _innerMap: Map< string, { status: MediaKeyStatus; policyLevel: number; } >; /** * KeySystem maintaining this Map. * Needed to perform some work-around on "special" platforms. */ private _keySystem: string; /** * @returns {number} - the number of elements in the `DummyMediaKeyStatusMap`. */ get size(): number { return this._innerMap.size; } /** * @param {string} keySystem - KeySystem linked to this Map. * Needed to perform some work-around on "special" platforms. */ constructor(keySystem: string) { this._innerMap = new Map(); this._keySystem = keySystem; } /** * Returns the actual Map backing this `DummyMediaKeyStatusMap`. * Useful to perform complex modifications. * @returns {Map} */ public getInnerMap(): Map<string, { status: MediaKeyStatus; policyLevel: number }> { return this._innerMap; } /** * Executes a provided function once per each key/value pair in the * `DummyMediaKeyStatusMap`, in insertion order. * @param {function} callbackfn */ public forEach( callbackfn: ( value: MediaKeyStatus, key: ArrayBuffer, parent: MediaKeyStatusMap, ) => void, ): void { return this._innerMap.forEach((value, key) => { const toLocalFormat = kidToPlatformKid( this._keySystem, toUint8Array(hexToBytes(key)), ); callbackfn(value.status, toLocalFormat.buffer, this); }); } /** * Adds a new element with a specified key id and `MediaKeyStatus` to the * `DummyMediaKeyStatusMap`. * If an element with the same key id already exists, the element will be * updated. * @param {string} key - Key as an hex string in lower case * @param {string} status * @param {number} policyLevel * @returns {Object} */ public set(key: string, status: MediaKeyStatus, policyLevel: number): this { this._innerMap.set(key, { status, policyLevel }); return this; } /** * Returns a specified element from this `DummyMediaKeyStatusMap` object. * @param {BufferSource} key * @returns {string|undefined} - Returns the element associated with the * specified key id. * If no element is associated with the specified key id, undefined is * returned. */ public get(key: BufferSource): MediaKeyStatus | undefined { const toLocalFormat = kidToPlatformKid(this._keySystem, toUint8Array(key)); const keyStr = bytesToHex(toLocalFormat); return this._innerMap.get(keyStr)?.status; } /** * @returns {boolean} - Indicate whether an element with the specified key id * exists or not. */ public has(key: BufferSource): boolean { const toLocalFormat = kidToPlatformKid(this._keySystem, toUint8Array(key)); const keyStr = bytesToHex(toLocalFormat); return this._innerMap.has(keyStr); } /** * Remove all stored key ids from this Map. */ public clear(): void { return this._innerMap.clear(); } } /* eslint-disable @typescript-eslint/naming-convention */ const SYSTEM_IDS: Record<string, "cenc" | "PlayReady" | "Nagra" | "Widevine"> = { "1077EFECC0B24D02ACE33C1E52E2FB4B": "cenc", // "1F83E1E86EE94F0DBA2F5EC4E3ED1A66": "SecureMedia", // "35BF197B530E42D78B651B4BF415070F": "DivX DRM", // "45D481CB8FE049C0ADA9AB2D2455B2F2": "CoreCrypt", // "5E629AF538DA4063897797FFBD9902D4": "Marlin", // "616C7469636173742D50726F74656374": "AltiProtect", // "644FE7B5260F4FAD949A0762FFB054B4": "CMLA", // "69F908AF481646EA910CCD5DCCCB0A3A": "Marlin", // "6A99532D869F59229A91113AB7B1E2F3": "MobiDRM", // "80A6BE7E14484C379E70D5AEBE04C8D2": "Irdeto", // "94CE86FB07FF4F43ADB893D2FA968CA2": "FairPlay", // "992C46E6C4374899B6A050FA91AD0E39": "SteelKnot", "9A04F07998404286AB92E65BE0885F95": "PlayReady", // "9A27DD82FDE247258CBC4234AA06EC09": "Verimatrix VCAS", // "A68129D3575B4F1A9CBA3223846CF7C3": "VideoGuard Everywhere", ADB41C242DBF4A6D958B4457C0D27B95: "Nagra", // "B4413586C58CFFB094A5D4896C1AF6C3": "Viaccess-Orca", // "DCF4E3E362F158187BA60A6FE33FF3DD": "DigiCAP", // E2719D58A985B3C9781AB030AF78D30E: "ClearKey", EDEF8BA979D64ACEA3C827DCD51D21ED: "Widevine", // "F239E769EFA348509C16A903C6932EFB": "PrimeTime", }; /* eslint-enable @typescript-eslint/naming-convention */ type ValueOf<T> = T[keyof T]; function getKeyIdsFromPssh( buf: Uint8Array, baseOffset: number, ): { systemId: ValueOf<typeof SYSTEM_IDS> | undefined; kids: Uint8Array[]; } | null { let offset = baseOffset + 4 + 4; const version = buf[offset]; if (version === undefined || version > 1) { throw new Error("Invalid PSSH: Invalid version"); } offset++; if (buf.length < offset + offset + 19) { throw new Error("Invalid PSSH: too short"); } offset += 3; // flags const systemId = bytesToHex(buf.subarray(offset, offset + 16)); offset += 16; if (version === 1) { if (buf.length < offset + 4) { return null; } const kidCount = be4toi(buf, offset); offset += 4; const kids = []; let i = kidCount; while (i-- > 0) { if (buf.length < offset + 16) { return null; } kids.push(buf.subarray(offset, offset + 16)); offset += 16; } return { systemId: undefined, kids, }; } const systemIdStr = SYSTEM_IDS[systemId.toUpperCase()]; switch (systemIdStr) { case "PlayReady": { const kid = getPlayReadyKIDFromPssh(buf, baseOffset); return { systemId: "PlayReady", kids: [hexToBytes(kid)], }; } case "Widevine": { let innerOffset = 4 /* box length */ + 4 /* box name */ + 4 /* version + flags */ + 16 /* system id */ + 4; /* length of widevine header. */ // TODO real widevine PSSH parsing. while (true) { if (buf.byteLength < baseOffset + innerOffset + 16 + 2) { return null; } if ( buf[baseOffset + innerOffset] === 0x12 && buf[baseOffset + innerOffset + 1] === 0x10 ) { const kid = buf.subarray( baseOffset + innerOffset + 2, baseOffset + innerOffset + 2 + 16, ); return { systemId: "Widevine", kids: [kid], }; } innerOffset += 1; } } case "Nagra": { const innerOffset = baseOffset + 4 /* box length */ + 4 /* box name */ + 4 /* version + flags */ + 16 /* system id */ + 4; /* length */ const nagraBase64 = utf8ToStr(buf.subarray(innerOffset)); const decodedBase64 = base64ToBytes(nagraBase64); const nagraStr = utf8ToStr(decodedBase64); const parsed = JSON.parse(nagraStr) as | { contentId?: string; keyId?: string; } | undefined | null; if (parsed === null || parsed === undefined || parsed.keyId === undefined) { throw new Error("Unrecognized Nagra PSSH"); } return { systemId: "Nagra", kids: [hexToBytes(parsed.keyId.replace(/-/g, ""))], }; } case "cenc": throw new Error("cenc pssh should have been set to version 1"); } } /** * Parse PlayReady pssh to get its Hexa-coded KeyID. * @param {Uint8Array} buf * @param {number} baseOffset * @returns {string} */ export function getPlayReadyKIDFromPssh(buf: Uint8Array, baseOffset: number): string { const innerOffset = baseOffset + 4 /* box length */ + 4 /* box name */ + 4 /* version + flags */ + 16; /* system id */ const xmlLength = le2toi(buf.subarray(innerOffset), 4); const xml = utf16LEToStr(buf.subarray(innerOffset + 14, innerOffset + 14 + xmlLength)); const doc = new DOMParser().parseFromString(xml, "application/xml"); const kidElement = doc.querySelector("KID"); if (kidElement === null) { throw new Error("Cannot parse PlayReady PSSH: invalid XML"); } const b64guidKid = kidElement.textContent === null ? "" : kidElement.textContent; const uuidKid = guidToUuid(base64ToBytes(b64guidKid)); return bytesToHex(uuidKid).toLowerCase(); } /** * @param {Uint8Array} data * @returns {Array.<Uint8Array>} - The extracted PSSH boxes. In the order they * are encountered. */ function splitPsshBoxes(data: Uint8Array): Uint8Array[] { let i = 0; const psshBoxes: Uint8Array[] = []; while (i < data.length) { let psshOffsets; try { psshOffsets = getBoxOffsets(data, 0x70737368 /* pssh */); } catch { return psshBoxes; } if (psshOffsets === null) { return psshBoxes; } const pssh = sliceUint8array(data, psshOffsets[0], psshOffsets[2]); psshBoxes.push(pssh); i = psshOffsets[2]; } return psshBoxes; } /** * On EDGE, Microsoft Playready KID are presented into little-endian GUID, this * function ensures that everything is in the expected format for the platfrm. * @param {String} keySystem * @param {Uint8Array} baseKeyId * @returns {Uint8Array} */ export default function kidToPlatformKid<T extends ArrayBufferLike>( keySystem: string, baseKeyId: Uint8Array<T>, ): Uint8Array<T | ArrayBuffer> { if ( keySystem.indexOf("playready") !== -1 && (EnvDetector.browser === EnvDetector.BROWSERS.EdgeChromium || EnvDetector.browser === EnvDetector.BROWSERS.OtherIeOrEdgePreEdgeChromium || EnvDetector.browser === EnvDetector.BROWSERS.Ie11) ) { return uuidToGuid(baseKeyId); } return baseKeyId; } /** * Convert big-endian UUID into little-endian GUID. * @param {Uint8Array} uuid * @returns {Uint8Array} - guid * @throws AssertionError - The uuid length is not 16 */ function uuidToGuid(uuid: Uint8Array): Uint8Array<ArrayBuffer> { assert(uuid.length === 16, "UUID length should be 16"); const p1A = uuid[0]; const p1B = uuid[1]; const p1C = uuid[2]; const p1D = uuid[3]; const p2A = uuid[4]; const p2B = uuid[5]; const p3A = uuid[6]; const p3B = uuid[7]; const guid = new Uint8Array(16); // swapping byte endian on 4 bytes // [4, 3, 2, 1] => [1, 2, 3, 4] guid[0] = p1D; guid[1] = p1C; guid[2] = p1B; guid[3] = p1A; // swapping byte endian on 2 bytes // [6, 5] => [5, 6] guid[4] = p2B; guid[5] = p2A; // swapping byte endian on 2 bytes // [8, 7] => [7, 8] guid[6] = p3B; guid[7] = p3A; guid.set(uuid.subarray(8, 16), 8); return guid; }