From 062ee4abe1a1ee70fcae9177ecd8ac195a1c9597 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Sat, 17 Dec 2022 11:49:37 -0800 Subject: [PATCH 1/2] FairPlay support improvements --- api-extractor/report/hls.js.api.md | 30 ++-- docs/API.md | 34 ++++- src/controller/eme-controller.ts | 237 ++++++++++++++--------------- src/errors.ts | 2 +- src/loader/level-key.ts | 78 +--------- src/utils/mediakeys-helper.ts | 8 +- src/utils/mp4-tools.ts | 44 +++--- 7 files changed, 193 insertions(+), 240 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 84a584ae7cd..c6c96c9a290 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -460,7 +460,7 @@ export enum ErrorDetails { // (undocumented) KEY_SYSTEM_NO_ACCESS = "keySystemNoAccess", // (undocumented) - KEY_SYSTEM_NO_INIT_DATA = "keySystemNoInitData", + KEY_SYSTEM_NO_CONFIGURED_LICENSE = "keySystemNoConfiguredLicense", // (undocumented) KEY_SYSTEM_NO_KEYS = "keySystemNoKeys", // (undocumented) @@ -2293,20 +2293,20 @@ export interface UserdataSample { // Warnings were encountered during analysis: // -// src/config.ts:87:3 - (ae-forgotten-export) The symbol "MediaKeySessionContext" needs to be exported by the entry point hls.d.ts -// src/config.ts:102:3 - (ae-forgotten-export) The symbol "DRMSystemsConfiguration" needs to be exported by the entry point hls.d.ts -// src/config.ts:205:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts -// src/config.ts:215:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:216:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:218:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:219:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:220:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts -// src/config.ts:222:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts -// src/config.ts:225:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts -// src/config.ts:227:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts -// src/config.ts:228:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts -// src/config.ts:229:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts -// src/config.ts:230:3 - (ae-forgotten-export) The symbol "FPSController" needs to be exported by the entry point hls.d.ts +// src/config.ts:88:3 - (ae-forgotten-export) The symbol "MediaKeySessionContext" needs to be exported by the entry point hls.d.ts +// src/config.ts:103:3 - (ae-forgotten-export) The symbol "DRMSystemsConfiguration" needs to be exported by the entry point hls.d.ts +// src/config.ts:206:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts +// src/config.ts:216:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:217:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:219:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:220:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:221:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts +// src/config.ts:223:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts +// src/config.ts:226:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts +// src/config.ts:228:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts +// src/config.ts:229:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts +// src/config.ts:230:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts +// src/config.ts:231:3 - (ae-forgotten-export) The symbol "FPSController" needs to be exported by the entry point hls.d.ts // (No @packageDocumentation comment for this package) diff --git a/docs/API.md b/docs/API.md index e07cb311cef..3064cb6200f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1552,7 +1552,7 @@ Full list of Events is available below: - `Hls.Events.MANIFEST_LOADING` - fired to signal that a manifest loading starts - data: { url : manifestURL } - `Hls.Events.MANIFEST_LOADED` - fired after manifest has been loaded - - data: { levels : [available quality levels], audioTracks : [available audio tracks], captions? [available closed-captions media], subtitles?: [available subtitle tracks], url : manifestURL, stats : [LoaderStats], sessionData: [parsed #EXT-X-SESSION-DATA], networkDetails: [Loader specific object for debugging (XHR or fetch Response)]} + - data: { levels : [available quality levels], audioTracks : [available audio tracks], captions? [available closed-captions media], subtitles?: [available subtitle tracks], url : manifestURL, stats : [LoaderStats], sessionData: [parsed #EXT-X-SESSION-DATA], networkDetails: [Loader specific object for debugging (XMLHttpRequest or fetch Response)]} - `Hls.Events.MANIFEST_PARSED` - fired after manifest has been parsed - data: { levels : [ available quality levels ], firstLevel : index of first quality level appearing in Manifest, audioTracks, subtitleTracks, stats, audio: boolean, video: boolean, altAudio: boolean } - `Hls.Events.LEVEL_SWITCHING` - fired when a level switch is requested @@ -1702,10 +1702,14 @@ Full list of errors is described below: - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.LEVEL_LOAD_ERROR`, fatal : `true`, url : level URL, response : { code: error code, text: error text }, loader : URL loader } - `Hls.ErrorDetails.LEVEL_LOAD_TIMEOUT` - raised when level loading fails because of a timeout - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.LEVEL_LOAD_TIMEOUT`, fatal : `false`, url : level URL, loader : URL loader } -- `Hls.ErrorDetails.AUDIO_TRACK_LOAD_ERROR` - raised when audio track loading fails because of a network error +- `Hls.ErrorDetails.AUDIO_TRACK_LOAD_ERROR` - raised when audio playlist loading fails because of a network error - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.AUDIO_TRACK_LOAD_ERROR`, fatal : `false`, url : audio URL, response : { code: error code, text: error text }, loader : URL loader } -- `Hls.ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT` - raised when audio track loading fails because of a timeout +- `Hls.ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT` - raised when audio playlist loading fails because of a timeout - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT`, fatal : `false`, url : audio URL, loader : URL loader } +- `Hls.ErrorDetails.SUBTITLE_LOAD_ERROR` - raised when subtitle playlist loading fails because of a network error + - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.SUBTITLE_LOAD_ERROR`, fatal : `false`, url, response : { code: error code, text: error text }, loader : URL loader } +- `Hls.ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT` - raised when subtitle playlist loading fails because of a timeout + - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT`, fatal : `false`, url, loader : URL loader } - `Hls.ErrorDetails.FRAG_LOAD_ERROR` - raised when fragment loading fails because of a network error - data: { type : `NETWORK_ERROR`, details : `Hls.ErrorDetails.FRAG_LOAD_ERROR`, fatal : `true` or `false`, frag : fragment object, response : { code: error code, text: error text } } - `Hls.ErrorDetails.FRAG_LOAD_TIMEOUT` - raised when fragment loading fails because of a timeout @@ -1746,12 +1750,36 @@ Full list of errors is described below: - `Hls.ErrorDetails.REMUX_ALLOC_ERROR` - raised when memory allocation fails during remuxing - data: { type : `MUX_ERROR`, details : `Hls.ErrorDetails.REMUX_ALLOC_ERROR`, fatal : `false`, bytes : mdat size, reason : failure reason } +### EME Key System Errors + +- `Hls.ErrorDetails.KEY_SYSTEM_NO_KEYS` - EME catch-all error + - data: { type : `KEY_SYSTEM_ERROR`, details : `Hls.ErrorDetails.KEY_SYSTEM_NO_KEYS`, fatal : `true`, error: Error } +- `Hls.ErrorDetails.KEY_SYSTEM_NO_ACCESS` - EME MediaKeyFunc `requestMediaKeySystemAccess(keySystem, supportedConfigurations)` failed to access key-system + - data: { type : `KEY_SYSTEM_ERROR`, details : `Hls.ErrorDetails.KEY_SYSTEM_NO_ACCESS`, fatal : `true`, error: Error } +- `Hls.ErrorDetails.KEY_SYSTEM_NO_SESSION` - MediaKeySession `generateRequest(initDataType, initData)` failed + - data: { type : `KEY_SYSTEM_ERROR`, details : `Hls.ErrorDetails.KEY_SYSTEM_NO_SESSION`, fatal : `false`, error: Error } +- `Hls.ErrorDetails.KEY_SYSTEM_NO_CONFIGURED_LICENSE` - Player configuration is missing `drmSystems` key-system license options + - data: { type : `KEY_SYSTEM_ERROR`, details : `Hls.ErrorDetails.KEY_SYSTEM_NO_CONFIGURED_LICENSE`, fatal : `false` } +- `Hls.ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED` - Key-system license request failed (fails on first status 4xx, or after 3 tries (EMEController MAX_LICENSE_REQUEST_FAILURES)) + - data: { type : `KEY_SYSTEM_ERROR`, details : `Hls.ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED`, fatal : `true`, networkDetails: XMLHttpRequest } +- `Hls.ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED` - Key-system certificate request failed + - data: { type : `KEY_SYSTEM_ERROR`, details : `Hls.ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED`, fatal : `true`, networkDetails: XMLHttpRequest } +- `Hls.ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED` - `MediaKeys.setServerCertificate(certificateData)` failed + - data: { type : `KEY_SYSTEM_ERROR`, details : `Hls.ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED`, fatal : `true`, error: Error } +- `Hls.ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED` - MediaKeySession `update(licenseResponse|acknowledged)` failed + - data: { type : `KEY_SYSTEM_ERROR`, details : `Hls.ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED`, fatal : `true`, error: Error } +- `Hls.ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED` - HDCP level output restricted for key-session + - data: { type : `KEY_SYSTEM_ERROR`, details : `Hls.ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED`, fatal : `false` } +- `Hls.ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR` - key-session status changed to "internal-error" + - data: { type : `KEY_SYSTEM_ERROR`, details : `Hls.ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR`, fatal : `true` } + ### Other Errors - `Hls.ErrorDetails.LEVEL_SWITCH_ERROR` - raised when level switching fails - data: { type : `OTHER_ERROR`, details : `Hls.ErrorDetails.LEVEL_SWITCH_ERROR`, fatal : `false`, level : failed level index, reason : failure reason } - `Hls.ErrorDetails.INTERNAL_EXCEPTION` - raised when an exception occurs in an internal hls.js event handler - data: { type : `OTHER_ERROR`, details : `Hls.ErrorDetails.INTERNAL_EXCEPTION`, fatal : `true` or `false`, event : event object or string, err : { message : error message } } +- `Hls.ErrorDetails.UNKNOWN` - Uncategorized error ## Objects diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index b064b5452f6..425fd43e558 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -20,11 +20,10 @@ import { requestMediaKeySystemAccess, } from '../utils/mediakeys-helper'; import { strToUtf8array } from '../utils/keysystem-util'; -import { utf8ArrayToStr } from '../demux/id3'; -import { base64Decode, base64Encode } from '../utils/numeric-encoding-utils'; +import { base64Decode } from '../utils/numeric-encoding-utils'; import { DecryptData, LevelKey } from '../loader/level-key'; import Hex from '../utils/hex'; -import { parsePssh } from '../utils/mp4-tools'; +import { bin2str, parsePssh, parseSinf } from '../utils/mp4-tools'; import EventEmitter from 'eventemitter3'; import type Hls from '../hls'; import type { ComponentAPI } from '../types/component-api'; @@ -321,17 +320,21 @@ class EMEController implements ComponentAPI { private renewKeySession(mediaKeySessionContext: MediaKeySessionContext) { const decryptdata = mediaKeySessionContext.decryptdata; - const keySessionContext = this.createMediaKeySessionContext( - mediaKeySessionContext - ); - const keyId = this.getKeyIdString(decryptdata); - const scheme = 'cenc'; - this.keyIdToKeySessionPromise[keyId] = - this.generateRequestWithPreferredKeySession( - keySessionContext, - scheme, - decryptdata.pssh + if (decryptdata.pssh) { + const keySessionContext = this.createMediaKeySessionContext( + mediaKeySessionContext ); + const keyId = this.getKeyIdString(decryptdata); + const scheme = 'cenc'; + this.keyIdToKeySessionPromise[keyId] = + this.generateRequestWithPreferredKeySession( + keySessionContext, + scheme, + decryptdata.pssh + ); + } else { + this.warn(`Could not renew expired session. Missing pssh initData.`); + } this.removeSession(mediaKeySessionContext); } @@ -345,27 +348,6 @@ class EMEController implements ComponentAPI { return Hex.hexDump(decryptdata.keyId); } - private handleParsedKeyResponse( - mediaKeySessionContext: MediaKeySessionContext, - licenseResponse: ArrayBuffer - ): Uint8Array { - switch (mediaKeySessionContext.keySystem) { - case KeySystems.FAIRPLAY: { - const responseStr = JSON.stringify([ - { - keyID: base64Encode( - mediaKeySessionContext.decryptdata?.keyId as Uint8Array - ), - payload: base64Encode(new Uint8Array(licenseResponse)), - }, - ]); - this.log(`processLicense msg=${responseStr}`); - return strToUtf8array(responseStr); - } - } - return new Uint8Array(licenseResponse); - } - private updateKeySession( mediaKeySessionContext: MediaKeySessionContext, data: Uint8Array @@ -500,11 +482,23 @@ class EMEController implements ComponentAPI { } private getKeySystemSelectionPromise( - keySystemsToAttempt?: KeySystems[] - ): Promise<{ keySystem: KeySystems; mediaKeys: MediaKeys }> { - if (!keySystemsToAttempt || !keySystemsToAttempt.length) { + keySystemsToAttempt: KeySystems[] + ): Promise<{ keySystem: KeySystems; mediaKeys: MediaKeys }> | never { + if (!keySystemsToAttempt.length) { keySystemsToAttempt = getKeySystemsForConfig(this.config); } + if (keySystemsToAttempt.length === 0) { + throw new EMEKeyError( + { + type: ErrorTypes.KEY_SYSTEM_ERROR, + details: ErrorDetails.KEY_SYSTEM_NO_CONFIGURED_LICENSE, + fatal: true, + }, + `Missing key-system license configuration options ${JSON.stringify({ + drmSystems: this.config.drmSystems, + })}` + ); + } return this.attemptKeySystemAccess(keySystemsToAttempt); } @@ -517,30 +511,84 @@ class EMEController implements ComponentAPI { return; } - // Support clear-lead key-session creation (otherwise depend on playlist keys) - const psshInfo = parsePssh(initData); - if (psshInfo === null) { - return; - } - let keyId: Uint8Array | null = null; + let keyId: Uint8Array | undefined; + let keySystemDomain: KeySystems | undefined; + if ( - psshInfo.version === 0 && - psshInfo.systemId === KeySystemIds.WIDEVINE && - psshInfo.data + initDataType === 'sinf' && + this.config.drmSystems[KeySystems.FAIRPLAY] ) { - keyId = psshInfo.data.subarray(8, 24); + // Match sinf keyId to playlist skd://keyId= + const json = bin2str(new Uint8Array(initData)); + try { + const sinf = base64Decode(JSON.parse(json).sinf); + const tenc = parseSinf(new Uint8Array(sinf)); + if (!tenc) { + return; + } + keyId = tenc.subarray(8, 24); + keySystemDomain = KeySystems.FAIRPLAY; + } catch (error) { + this.warn('Failed to parse sinf "encrypted" event message initData'); + return; + } + } else { + // Support clear-lead key-session creation (otherwise depend on playlist keys) + const psshInfo = parsePssh(initData); + if (psshInfo === null) { + return; + } + if ( + psshInfo.version === 0 && + psshInfo.systemId === KeySystemIds.WIDEVINE && + psshInfo.data + ) { + keyId = psshInfo.data.subarray(8, 24); + } + keySystemDomain = keySystemIdToKeySystemDomain( + psshInfo.systemId as KeySystemIds + ); } - const keySystemDomain = keySystemIdToKeySystemDomain( - psshInfo.systemId as KeySystemIds - ); + if (!keySystemDomain || !keyId) { return; } const keyIdHex = Hex.hexDump(keyId); - let keySessionContextPromise = this.keyIdToKeySessionPromise[keyIdHex]; + const { keyIdToKeySessionPromise, mediaKeySessions } = this; + + let keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex]; + for (let i = 0; i < mediaKeySessions.length; i++) { + // Match playlist key + const keyContext = mediaKeySessions[i]; + const decryptdata = keyContext.decryptdata; + if (decryptdata.pssh || !decryptdata.keyId) { + continue; + } + const oldKeyIdHex = Hex.hexDump(decryptdata.keyId); + if ( + keyIdHex === oldKeyIdHex || + decryptdata.uri.replace(/-/g, '').indexOf(keyIdHex) !== -1 + ) { + keySessionContextPromise = keyIdToKeySessionPromise[oldKeyIdHex]; + delete keyIdToKeySessionPromise[oldKeyIdHex]; + decryptdata.pssh = new Uint8Array(initData); + decryptdata.keyId = keyId; + keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex] = + keySessionContextPromise.then(() => { + return this.generateRequestWithPreferredKeySession( + keyContext, + initDataType, + initData + ); + }); + break; + } + } + if (!keySessionContextPromise) { - keySessionContextPromise = this.keyIdToKeySessionPromise[keyIdHex] = + // Clear-lead key (not encountered in playlist) + keySessionContextPromise = keyIdToKeySessionPromise[keyIdHex] = this.getKeySystemSelectionPromise([keySystemDomain]).then( ({ keySystem, mediaKeys }) => { this.throwIfDestroyed(); @@ -550,7 +598,7 @@ class EMEController implements ComponentAPI { keySystemToKeySystemFormat(keySystem) ?? '' ); decryptdata.pssh = new Uint8Array(initData); - decryptdata.keyId = keyId; + decryptdata.keyId = keyId as Uint8Array; return this.attemptSetMediaKeys(keySystem, mediaKeys).then(() => { this.throwIfDestroyed(); const keySessionContext = this.createMediaKeySessionContext({ @@ -616,22 +664,23 @@ class EMEController implements ComponentAPI { initData, context ); + if (!mappedInitData) { + throw new Error( + 'Invalid response from configured generateRequest filter' + ); + } initDataType = mappedInitData.initDataType; - initData = mappedInitData.initData; + initData = context.decryptdata.pssh = new Uint8Array( + mappedInitData.initData + ); } catch (error) { this.error(error); } } - if (!initData) { - throw new EMEKeyError( - { - type: ErrorTypes.KEY_SYSTEM_ERROR, - details: ErrorDetails.KEY_SYSTEM_NO_INIT_DATA, - fatal: true, - }, - 'Fatal: initData required for generating a key session is null' - ); + if (initData === null) { + // wait for media "encrypted" event to generate request + return Promise.resolve(context); } const keyId = this.getKeyIdString(context.decryptdata); @@ -799,7 +848,7 @@ class EMEController implements ComponentAPI { fatal: true, networkDetails: xhr, }, - `HTTP error ${xhr.status} happened while fetching server certificate` + `"${keySystem}" certificate request XHR failed (${url}). Status: ${xhr.status} (${xhr.statusText})` ) ); } @@ -846,17 +895,9 @@ class EMEController implements ComponentAPI { context: MediaKeySessionContext, keyMessage: ArrayBuffer ): Promise { - const licenseChallenge = this.generateLicenseRequestChallenge( - context, - keyMessage - ); - return this.requestLicense(context, licenseChallenge).then( + return this.requestLicense(context, new Uint8Array(keyMessage)).then( (data: ArrayBuffer) => { - const licenseResponse: Uint8Array = this.handleParsedKeyResponse( - context, - data - ); - return this.updateKeySession(context, licenseResponse).catch( + return this.updateKeySession(context, new Uint8Array(data)).catch( (error) => { throw new EMEKeyError( { @@ -965,9 +1006,6 @@ class EMEController implements ComponentAPI { } resolve(data); } else { - const error = new Error( - `License Request XHR failed (${url}). Status: ${xhr.status} (${xhr.statusText})` - ); this._requestLicenseFailureCount++; if ( this._requestLicenseFailureCount > MAX_LICENSE_REQUEST_FAILURES || @@ -978,11 +1016,10 @@ class EMEController implements ComponentAPI { { type: ErrorTypes.KEY_SYSTEM_ERROR, details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED, - error, fatal: true, networkDetails: xhr, }, - error.message + `License Request XHR failed (${url}). Status: ${xhr.status} (${xhr.statusText})` ) ); } else { @@ -1017,48 +1054,6 @@ class EMEController implements ComponentAPI { }); } - private generateLicenseRequestChallenge( - keySessionContext: MediaKeySessionContext, - keyMessage: ArrayBuffer - ): Uint8Array | never { - const message = new Uint8Array(keyMessage); - switch (keySessionContext.keySystem) { - case KeySystems.FAIRPLAY: { - if (keySessionContext.decryptdata?.keyId) { - const messageJson = utf8ArrayToStr(message); - try { - const spcArray = JSON.parse(messageJson); - const keyID = base64Encode(keySessionContext.decryptdata.keyId); - // this.log(`License challenge message with key IDs: ${spcArray.map(p => p.keyID).join(', ')}`); - for (let i = 0; i < spcArray.length; i++) { - const payload = spcArray[i]; - if (payload.keyID === keyID) { - this.log( - `Generateing license challenge with ID ${payload.keyID}` - ); - const spc = base64Decode(payload.payload); - return spc; - } - } - } catch (error) { - this.warn( - `Failed to extract spc from FairPlay license-request message. Fallback to message data for key uri: "${keySessionContext.decryptdata.uri}"` - ); - } - } - return message; - } - case KeySystems.WIDEVINE: - case KeySystems.PLAYREADY: - case KeySystems.CLEARKEY: - return message; - default: - throw new Error( - `unsupported key-system: ${keySessionContext.keySystem}` - ); - } - } - private onMediaAttached( event: Events.MEDIA_ATTACHED, data: MediaAttachedData diff --git a/src/errors.ts b/src/errors.ts index 3271d8e3836..b1662631ee3 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -19,10 +19,10 @@ export enum ErrorDetails { KEY_SYSTEM_NO_KEYS = 'keySystemNoKeys', KEY_SYSTEM_NO_ACCESS = 'keySystemNoAccess', KEY_SYSTEM_NO_SESSION = 'keySystemNoSession', + KEY_SYSTEM_NO_CONFIGURED_LICENSE = 'keySystemNoConfiguredLicense', KEY_SYSTEM_LICENSE_REQUEST_FAILED = 'keySystemLicenseRequestFailed', KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED = 'keySystemServerCertificateRequestFailed', KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED = 'keySystemServerCertificateUpdateFailed', - KEY_SYSTEM_NO_INIT_DATA = 'keySystemNoInitData', KEY_SYSTEM_SESSION_UPDATE_FAILED = 'keySystemSessionUpdateFailed', KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED = 'keySystemStatusOutputRestricted', KEY_SYSTEM_STATUS_INTERNAL_ERROR = 'keySystemStatusInternalError', diff --git a/src/loader/level-key.ts b/src/loader/level-key.ts index 8a7a6e18e31..c68c57103ea 100644 --- a/src/loader/level-key.ts +++ b/src/loader/level-key.ts @@ -1,10 +1,9 @@ import { changeEndianness, convertDataUriToArrayBytes, - strToUtf8array, } from '../utils/keysystem-util'; import { KeySystemFormats } from '../utils/mediakeys-helper'; -import { mp4Box, mp4pssh, writeUint32 } from '../utils/mp4-tools'; +import { mp4pssh } from '../utils/mp4-tools'; import { logger } from '../utils/logger'; import { base64Decode } from '../utils/numeric-encoding-utils'; @@ -125,16 +124,6 @@ export class LevelKey implements DecryptData { ); } break; - case KeySystemFormats.FAIRPLAY: { - let keydata = keyBytes.subarray(0, 16); - if (keydata.length !== 16) { - const padded = new Uint8Array(16); - padded.set(keydata, 16 - keydata.length); - keydata = padded; - } - this.keyId = keydata; - break; - } case KeySystemFormats.PLAYREADY: { const PlayReadyKeySystemUUID = new Uint8Array([ 0x9a, 0x04, 0xf0, 0x79, 0x98, 0x40, 0x42, 0x86, 0xab, 0x92, 0xe6, @@ -202,14 +191,6 @@ export class LevelKey implements DecryptData { this.keyId = keyId; } - if (this.keyFormat === KeySystemFormats.FAIRPLAY) { - this.pssh = getFairPlayV3Pssh( - this.keyId, - this.method, - this.keyFormatVersions - ); - } - return this; } } @@ -221,60 +202,3 @@ function createInitializationVector(segmentNumber: number): Uint8Array { } return uint8View; } - -function getFairPlayV3Pssh( - keyId: Uint8Array, - method: string, - keyFormatVersions: number[] -): Uint8Array { - enum SchemeFourCC { - CENC = 0x63656e63, - CBCS = 0x63626373, - } - const scheme = - method === 'ISO-23001-7' ? SchemeFourCC.CENC : SchemeFourCC.CBCS; - const FpsBoxTypes = { - fpsd: strToUtf8array('fpsd'), // Parent box containing all info - fpsi: strToUtf8array('fpsi'), // Common info - fpsk: strToUtf8array('fpsk'), // key request - fkri: strToUtf8array('fkri'), // key request info - fkvl: strToUtf8array('fkvl'), // version list - }; - const makeFpsKeySystemInfoBox = (scheme: SchemeFourCC): Uint8Array => { - const schemeArray = new Uint8Array(4); - writeUint32(schemeArray, 0, scheme); - return mp4Box(FpsBoxTypes.fpsi, new Uint8Array([0, 0, 0, 0]), schemeArray); - }; - const makeFpsKeyRequestBox = ( - keyId: Uint8Array, - versionList: Array - ): Uint8Array => { - const args = [ - FpsBoxTypes.fpsk, - mp4Box(FpsBoxTypes.fkri, new Uint8Array([0x00, 0x00, 0x00, 0x00]), keyId), - ]; - if (versionList.length) { - // List of integers - const versionListBuffer = new Uint8Array(4 * versionList.length); - let pos = 0; - for (const version of versionList) { - writeUint32(versionListBuffer, pos, version); - pos += 4; - } - args.push(mp4Box(FpsBoxTypes.fkvl, versionListBuffer)); - } - - const fpsk = mp4Box.apply(null, args as [ArrayLike, Uint8Array]); - return fpsk; - }; - const kFairPlayStreamingKeySystemUUID = new Uint8Array([ - 0x94, 0xce, 0x86, 0xfb, 0x07, 0xff, 0x4f, 0x43, 0xad, 0xb8, 0x93, 0xd2, - 0xfa, 0x96, 0x8c, 0xa2, - ]); - const data = mp4Box( - FpsBoxTypes.fpsd, - makeFpsKeySystemInfoBox(scheme), - makeFpsKeyRequestBox(keyId, keyFormatVersions) - ); - return mp4pssh(kFairPlayStreamingKeySystemUUID, null, data); -} diff --git a/src/utils/mediakeys-helper.ts b/src/utils/mediakeys-helper.ts index b00dad42d9b..2f23e5ec036 100644 --- a/src/utils/mediakeys-helper.ts +++ b/src/utils/mediakeys-helper.ts @@ -116,15 +116,15 @@ export function getSupportedMediaKeySystemConfigurations( let initDataTypes: string[]; switch (keySystem) { case KeySystems.FAIRPLAY: - initDataTypes = ['cenc', 'sinf', 'skd']; + initDataTypes = ['cenc', 'sinf']; break; case KeySystems.WIDEVINE: - case KeySystems.CLEARKEY: - initDataTypes = ['cenc', 'keyids']; - break; case KeySystems.PLAYREADY: initDataTypes = ['cenc']; break; + case KeySystems.CLEARKEY: + initDataTypes = ['cenc', 'keyids']; + break; default: throw new Error(`Unknown key-system: ${keySystem}`); } diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index 7a260bf6f27..f68993f06a5 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -299,25 +299,19 @@ export function patchEncyptionData( const encBoxChildren = isAudio ? enc.subarray(28) : enc.subarray(78); const sinfBoxes = findBox(encBoxChildren, ['sinf']); sinfBoxes.forEach((sinf) => { - const schm = findBox(sinf, ['schm'])[0]; - if (!schm) { - logger.error(`[eme] missing 'schm' box`); - return; - } - const scheme = bin2str(schm.subarray(4, 8)); - if (scheme === 'cbcs' || scheme === 'cenc') { - const tenc = findBox(sinf, ['schi', 'tenc'])[0]; - if (tenc) { - // Look for default key id (keyID offset is always 8 within the tenc box): - const tencKeyId = tenc.subarray(8, 24); - if (!tencKeyId.some((b) => b !== 0)) { - logger.log( - `[eme] found 'tenc' patching map with keyId default: ${Hex.hexDump( - tencKeyId - )} -> ${Hex.hexDump(keyId)}` - ); - tenc.set(keyId, 8); - } + const tenc = parseSinf(sinf); + if (tenc) { + // Look for default key id (keyID offset is always 8 within the tenc box): + const tencKeyId = tenc.subarray(8, 24); + if (!tencKeyId.some((b) => b !== 0)) { + logger.log( + `[eme] Patching keyId in 'enc${ + isAudio ? 'a' : 'v' + }>sinf>>tenc' box: ${Hex.hexDump(tencKeyId)} -> ${Hex.hexDump( + keyId + )}` + ); + tenc.set(keyId, 8); } } }); @@ -328,6 +322,18 @@ export function patchEncyptionData( return initSegment; } +export function parseSinf(sinf: Uint8Array): Uint8Array | null { + const schm = findBox(sinf, ['schm'])[0]; + if (schm) { + const scheme = bin2str(schm.subarray(4, 8)); + if (scheme === 'cbcs' || scheme === 'cenc') { + return findBox(sinf, ['schi', 'tenc'])[0]; + } + } + logger.error(`[eme] missing 'schm' box`); + return null; +} + /** * Determine the base media decode start time, in seconds, for an MP4 * fragment. If multiple fragments are specified, the earliest time is From f2151d541bcb3e476293626a2100753fdd5b2cd6 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Sun, 18 Dec 2022 13:11:12 -0800 Subject: [PATCH 2/2] Document generateRequest filter and improve logging around CDM session setup --- api-extractor/report/hls.js.api.md | 28 +++++++++---------- docs/API.md | 20 +++++++++++-- src/config.ts | 7 +++-- src/controller/eme-controller.ts | 45 ++++++++++++++++++------------ src/utils/mp4-tools.ts | 3 +- 5 files changed, 64 insertions(+), 39 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index c6c96c9a290..9a84848663c 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -2293,20 +2293,20 @@ export interface UserdataSample { // Warnings were encountered during analysis: // -// src/config.ts:88:3 - (ae-forgotten-export) The symbol "MediaKeySessionContext" needs to be exported by the entry point hls.d.ts -// src/config.ts:103:3 - (ae-forgotten-export) The symbol "DRMSystemsConfiguration" needs to be exported by the entry point hls.d.ts -// src/config.ts:206:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts -// src/config.ts:216:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:217:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:219:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts -// src/config.ts:220:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts -// src/config.ts:221:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts -// src/config.ts:223:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts -// src/config.ts:226:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts -// src/config.ts:228:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts -// src/config.ts:229:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts -// src/config.ts:230:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts -// src/config.ts:231:3 - (ae-forgotten-export) The symbol "FPSController" needs to be exported by the entry point hls.d.ts +// src/config.ts:90:3 - (ae-forgotten-export) The symbol "MediaKeySessionContext" needs to be exported by the entry point hls.d.ts +// src/config.ts:105:3 - (ae-forgotten-export) The symbol "DRMSystemsConfiguration" needs to be exported by the entry point hls.d.ts +// src/config.ts:208:3 - (ae-forgotten-export) The symbol "ILogger" needs to be exported by the entry point hls.d.ts +// src/config.ts:218:3 - (ae-forgotten-export) The symbol "AudioStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:219:3 - (ae-forgotten-export) The symbol "AudioTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:221:3 - (ae-forgotten-export) The symbol "SubtitleStreamController" needs to be exported by the entry point hls.d.ts +// src/config.ts:222:3 - (ae-forgotten-export) The symbol "SubtitleTrackController" needs to be exported by the entry point hls.d.ts +// src/config.ts:223:3 - (ae-forgotten-export) The symbol "TimelineController" needs to be exported by the entry point hls.d.ts +// src/config.ts:225:3 - (ae-forgotten-export) The symbol "EMEController" needs to be exported by the entry point hls.d.ts +// src/config.ts:228:3 - (ae-forgotten-export) The symbol "CMCDController" needs to be exported by the entry point hls.d.ts +// src/config.ts:230:3 - (ae-forgotten-export) The symbol "AbrController" needs to be exported by the entry point hls.d.ts +// src/config.ts:231:3 - (ae-forgotten-export) The symbol "BufferController" needs to be exported by the entry point hls.d.ts +// src/config.ts:232:3 - (ae-forgotten-export) The symbol "CapLevelController" needs to be exported by the entry point hls.d.ts +// src/config.ts:233:3 - (ae-forgotten-export) The symbol "FPSController" needs to be exported by the entry point hls.d.ts // (No @packageDocumentation comment for this package) diff --git a/docs/API.md b/docs/API.md index 3064cb6200f..f4521a74c47 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1265,19 +1265,33 @@ var config = { (default: `{}`) -Set `licenseUrl` and `serverCertificateUrl` for a given key-system to your own DRM provider. `serverCertificateUrl` is not mandatory. Ex: +Define license settings for given key-systems according to your own DRM provider. Ex: ```js drmSystems: { + 'com.apple.fps': { + licenseUrl: 'https://your-fps-license-server/path', + serverCertificateUrl: 'https://your-fps-license-server/certificate/path', + }, 'com.widevine.alpha': { - licenseUrl: 'https://your-widevine-license-server/path', - serverCertificateUrl: 'https://optional-server-certificate/path/cert.bin' + licenseUrl: 'https://your-widevine-license-server/path' } } ``` Supported key-systems include 'com.apple.fps', 'com.microsoft.playready', 'com.widevine.alpha', and 'org.w3.clearkey'. Mapping to other values in key-system access requests can be done by customizing [`requestMediaKeySystemAccessFunc`](#requestMediaKeySystemAccessFunc). +When loading content with DRM Keys, the player will only request access +to key-systems for the Session Keys or Playlist Keys for which there are +also key-systems defined in `drmSystems`. + +### `drmSystems[KEY-SYSTEM].generateRequest + +(default: `undefined`, type `(initDataType: string, initData: ArrayBuffer | null, keyContext: MediaKeySessionContext) => { initDataType: string; initData: ArrayBuffer | null } | undefined`) + +Used to map initData or generate initData for playlist keys before +MediaKeySession `generateRequest` is called. + ### `drmSystemOptions` (default: `{}`) diff --git a/src/config.ts b/src/config.ts index 341fc8fbb66..3677d8e025c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -74,9 +74,12 @@ export type DRMSystemConfiguration = { generateRequest?: ( this: Hls, initDataType: string, - initData: ArrayBuffer, + initData: ArrayBuffer | null, keyContext: MediaKeySessionContext - ) => { initDataType: string; initData: ArrayBuffer }; + ) => + | { initDataType: string; initData: ArrayBuffer | null } + | undefined + | never; }; export type DRMSystemsConfiguration = Partial< diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index 425fd43e558..5fd0e3253fd 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -82,6 +82,7 @@ class EMEController implements ComponentAPI { private onMediaEncrypted = this._onMediaEncrypted.bind(this); private onWaitingForKey = this._onWaitingForKey.bind(this); + private debug: (msg: any) => void = logger.debug.bind(logger, LOGGER_PREFIX); private log: (msg: any) => void = logger.log.bind(logger, LOGGER_PREFIX); private warn: (msg: any) => void = logger.warn.bind(logger, LOGGER_PREFIX); private error: (msg: any) => void = logger.error.bind(logger, LOGGER_PREFIX); @@ -330,7 +331,8 @@ class EMEController implements ComponentAPI { this.generateRequestWithPreferredKeySession( keySessionContext, scheme, - decryptdata.pssh + decryptdata.pssh, + 'expired' ); } else { this.warn(`Could not renew expired session. Missing pssh initData.`); @@ -429,7 +431,8 @@ class EMEController implements ComponentAPI { return this.generateRequestWithPreferredKeySession( keySessionContext, scheme, - decryptdata.pssh + decryptdata.pssh, + 'playlist-key' ); }); } @@ -504,7 +507,7 @@ class EMEController implements ComponentAPI { private _onMediaEncrypted(event: MediaEncryptedEvent) { const { initDataType, initData } = event; - this.log(`"${event.type}" event: init data type: "${initDataType}"`); + this.debug(`"${event.type}" event: init data type: "${initDataType}"`); // Ignore event when initData is null if (initData === null) { @@ -579,7 +582,8 @@ class EMEController implements ComponentAPI { return this.generateRequestWithPreferredKeySession( keyContext, initDataType, - initData + initData, + 'encrypted-event-key-match' ); }); break; @@ -609,7 +613,8 @@ class EMEController implements ComponentAPI { return this.generateRequestWithPreferredKeySession( keySessionContext, initDataType, - initData + initData, + 'encrypted-event-no-match' ); }); } @@ -652,40 +657,44 @@ class EMEController implements ComponentAPI { private generateRequestWithPreferredKeySession( context: MediaKeySessionContext, initDataType: string, - initData: ArrayBuffer | null + initData: ArrayBuffer | null, + reason: + | 'playlist-key' + | 'encrypted-event-key-match' + | 'encrypted-event-no-match' + | 'expired' ): Promise | never { const generateRequestFilter = this.config.drmSystems?.[context.keySystem]?.generateRequest; if (generateRequestFilter) { try { - const mappedInitData = generateRequestFilter.call( - this.hls, - initDataType, - initData, - context - ); + const mappedInitData: ReturnType = + generateRequestFilter.call(this.hls, initDataType, initData, context); if (!mappedInitData) { throw new Error( 'Invalid response from configured generateRequest filter' ); } initDataType = mappedInitData.initDataType; - initData = context.decryptdata.pssh = new Uint8Array( - mappedInitData.initData - ); + initData = context.decryptdata.pssh = mappedInitData.initData + ? new Uint8Array(mappedInitData.initData) + : null; } catch (error) { - this.error(error); + this.warn(error.message); + if (this.hls?.config.debug) { + throw error; + } } } if (initData === null) { - // wait for media "encrypted" event to generate request + this.log(`Skipping key-session request for "${reason}" (no initData)`); return Promise.resolve(context); } const keyId = this.getKeyIdString(context.decryptdata); this.log( - `Generating key-session request for ${keyId} (init data type: ${initDataType} length: ${ + `Generating key-session request for "${reason}": ${keyId} (init data type: ${initDataType} length: ${ initData ? initData.byteLength : null })` ); diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index f68993f06a5..2edd548cd11 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -1032,8 +1032,7 @@ export function parseEmsg(data: Uint8Array): IEmsgParsingData { }; } -export function mp4Box(type: ArrayLike, ...params: Uint8Array[]) { - const payload = Array.prototype.slice.call(arguments, 1); +export function mp4Box(type: ArrayLike, ...payload: Uint8Array[]) { const len = payload.length; let size = 8; let i = len;