diff --git a/src/config.ts b/src/config.ts index d8bb7dd57e8..e206214d978 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,10 +44,16 @@ export type DRMSystemOptions = { videoRobustness?: string, } +export interface KeyidValue { + [keyid: string] : string; +} + export type EMEControllerConfig = { licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void, emeEnabled: boolean, widevineLicenseUrl?: string, + clearkeyServerUrl?: string, + clearkeyPair: KeyidValue | null, drmSystemOptions: DRMSystemOptions, requestMediaKeySystemAccessFunc: MediaKeyFunc | null, }; @@ -244,6 +250,8 @@ export const hlsDefaultConfig: HlsConfig = { maxLoadingDelay: 4, // used by abr-controller minAutoBitrate: 0, // used by hls emeEnabled: false, // used by eme-controller + clearkeyServerUrl: void 0, + clearkeyPair: null, widevineLicenseUrl: void 0, // used by eme-controller drmSystemOptions: {}, // used by eme-controller requestMediaKeySystemAccessFunc: requestMediaKeySystemAccess, // used by eme-controller diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index ad84c07b8bb..f382923e1ac 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -7,7 +7,7 @@ import { Events } from '../events'; import { ErrorTypes, ErrorDetails } from '../errors'; import { logger } from '../utils/logger'; -import { DRMSystemOptions, EMEControllerConfig } from '../config'; +import { DRMSystemOptions, EMEControllerConfig, KeyidValue } from '../config'; import { KeySystems, MediaKeyFunc } from '../utils/mediakeys-helper'; import Hls from '../hls'; import { ComponentAPI } from '../types/component-api'; @@ -56,6 +56,36 @@ const createWidevineMediaKeySystemConfigurations = function ( ]; }; +const createClearkeyMediaKeySystemConfigurations = function ( + audioCodecs: string[], + videoCodecs: string[] +): MediaKeySystemConfiguration[] { /* jshint ignore:line */ + const baseConfig: MediaKeySystemConfiguration = { + initDataTypes: ['keyids', 'mp4'], + // label: "", + // persistentState: "not-allowed", // or "required" ? + // distinctiveIdentifier: "not-allowed", // or "required" ? + // sessionTypes: ['temporary'], + audioCapabilities: [], // { contentType: 'audio/mp4; codecs="mp4a.40.2"' } + videoCapabilities: [] // { contentType: 'video/mp4; codecs="avc1.42E01E"' } + }; + + audioCodecs.forEach((codec) => { + baseConfig.audioCapabilities!.push({ + contentType: `audio/mp4; codecs="${codec}"` + }); + }); + videoCodecs.forEach((codec) => { + // logger.log(codec); + baseConfig.videoCapabilities!.push({ + contentType: `video/mp4; codecs="${codec}"` + }); + }); + return [ + baseConfig + ]; +}; + /** * The idea here is to handle key-system (and their respective platforms) specific configuration differences * in order to work with the local requestMediaKeySystemAccess method. @@ -77,6 +107,8 @@ const getSupportedMediaKeySystemConfigurations = function ( switch (keySystem) { case KeySystems.WIDEVINE: return createWidevineMediaKeySystemConfigurations(audioCodecs, videoCodecs, drmSystemOptions); + case KeySystems.CLEARKEY: + return createClearkeyMediaKeySystemConfigurations(audioCodecs, videoCodecs); default: throw new Error(`Unknown key-system: ${keySystem}`); } @@ -102,6 +134,8 @@ class EMEController implements ComponentAPI { private _widevineLicenseUrl?: string; private _licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void; private _emeEnabled: boolean; + private _clearkeyServerUrl?: string; + private _clearkeyPair: KeyidValue | null; private _requestMediaKeySystemAccess: MediaKeyFunc | null; private _drmSystemOptions: DRMSystemOptions; @@ -124,6 +158,8 @@ class EMEController implements ComponentAPI { this._widevineLicenseUrl = this._config.widevineLicenseUrl; this._licenseXhrSetup = this._config.licenseXhrSetup; this._emeEnabled = this._config.emeEnabled; + this._clearkeyServerUrl = this._config.clearkeyServerUrl; + this._clearkeyPair = this._config.clearkeyPair; this._requestMediaKeySystemAccess = this._config.requestMediaKeySystemAccessFunc; this._drmSystemOptions = this._config.drmSystemOptions; @@ -158,6 +194,11 @@ class EMEController implements ComponentAPI { break; } return this._widevineLicenseUrl; + case KeySystems.CLEARKEY: + if (!this._clearkeyServerUrl) { + break; + } + return this._clearkeyServerUrl; } throw new Error(`no license server URL configured for key-system "${keySystem}"`); @@ -248,6 +289,89 @@ class EMEController implements ComponentAPI { }); } + private _handleMessage (keySession: MediaKeySession, message: ArrayBuffer) { + // If you had a license server, you would make an asynchronous XMLHttpRequest + // with event.message as the body. The response from the server, as a + // Uint8Array, would then be passed to session.update(). + // Instead, we will generate the license synchronously on the client, using + // the hard-coded KEY. + if (!this._clearkeyPair) { + logger.error('Failed to load the keys'); + } + + const license = this._generateLicense(message); + + keySession.update(license).catch( + function (error) { + logger.error('Failed to update the session', error); + } + ); + logger.log(`Received license data (length: ${license ? license.byteLength : license}), updating key-session`); + } + + private _generateLicense (message) { + // Parse the clearkey license request. + const request = JSON.parse(new TextDecoder().decode(message)); + type responseFormat = { + kty?: string, + alg?: string, + kid?: string, + k?: string + }; + + const keyarray: responseFormat[] = []; + for (const id of request.kids) { + const decodedBase64 = this._base64ToHex(id); + // logger.log(`decodedBase64: ${decodedBase64}`); + if (!(this._clearkeyPair as KeyidValue).hasOwnProperty(decodedBase64)) { + logger.error('No pair key, please use lower case'); + } + keyarray.push( + { + kty: 'oct', + alg: 'A128KW', + kid: id, + k: this._hexToBase64((this._clearkeyPair as KeyidValue)[decodedBase64]) + // k: "aeqoAqZ2Ovl56NGUD7iDkg" + } + ); + } + + logger.log(JSON.stringify({ + keys: keyarray, + type: 'temporary' + })); + + return new TextEncoder().encode(JSON.stringify({ + keys: keyarray, + type: 'temporary' + })); + } + + private _hexToBase64 (hexstring) { + var encodedBase64 = btoa(hexstring.match(/\w{2}/g).map(function (a) { + return String.fromCharCode(parseInt(a, 16)); + }).join('')); + + var start = 0; + var end = encodedBase64.length; + while (end > start && encodedBase64[end - 1] === '=') { + --end; + } + return (start > 0 || end < encodedBase64.length) ? encodedBase64.substring(start, end) : encodedBase64; + } + + private _base64ToHex (str) { + const raw = atob(str); + logger.log(raw); + let result = ''; + for (let i = 0; i < raw.length; i++) { + const hex = raw.charCodeAt(i).toString(16); + result += (hex.length === 2 ? hex : '0' + hex); + } + return result.toLowerCase(); + } + /** * @private * @param {*} keySession @@ -268,10 +392,14 @@ class EMEController implements ComponentAPI { private _onKeySessionMessage (keySession: MediaKeySession, message: ArrayBuffer) { logger.log('Got EME message event, creating license request'); - this._requestLicense(message, (data: ArrayBuffer) => { - logger.log(`Received license data (length: ${data ? data.byteLength : data}), updating key-session`); - keySession.update(data); - }); + if (this._clearkeyPair && !this._clearkeyServerUrl) { + this._handleMessage(keySession, message); + } else { + this._requestLicense(message, (data: ArrayBuffer) => { + logger.log(`Received license data (length: ${data ? data.byteLength : data}), updating key-session`); + keySession.update(data); + }); + } } /** @@ -501,6 +629,10 @@ class EMEController implements ComponentAPI { case KeySystems.WIDEVINE: // For Widevine CDMs, the challenge is the keyMessage. return keyMessage; + case KeySystems.CLEARKEY: + // For CLEARKEY, the challenge is the keyMessage. + return keyMessage; + // return JSON.parse(new TextDecoder().decode(keyMessage)); } throw new Error(`unsupported key-system: ${keysListItem.mediaKeySystemDomain}`); @@ -530,7 +662,14 @@ class EMEController implements ComponentAPI { const xhr = this._createLicenseXhr(url, keyMessage, callback); logger.log(`Sending license request to URL: ${url}`); const challenge = this._generateLicenseRequestChallenge(keysListItem, keyMessage); - xhr.send(challenge); + switch (keysListItem.mediaKeySystemDomain) { + case KeySystems.WIDEVINE: + xhr.send(challenge); + case KeySystems.CLEARKEY: + // xhr.setRequestHeader('content-type', 'application/json') + // xhr.send(JSON.stringify(challenge)); + xhr.send(challenge); + } } catch (e) { logger.error(`Failure requesting DRM license: ${e}`); this.hls.trigger(Events.ERROR, { @@ -589,8 +728,11 @@ class EMEController implements ComponentAPI { const videoCodecs = data.levels.map((level) => level.videoCodec).filter( (videoCodec: string | undefined): videoCodec is string => !!videoCodec ); - - this._attemptKeySystemAccess(KeySystems.WIDEVINE, audioCodecs, videoCodecs); + if (this._clearkeyPair || this._clearkeyServerUrl) { + this._attemptKeySystemAccess(KeySystems.CLEARKEY, audioCodecs, videoCodecs); + } else { + this._attemptKeySystemAccess(KeySystems.WIDEVINE, audioCodecs, videoCodecs); + } } } diff --git a/src/demux/mp4demuxer.ts b/src/demux/mp4demuxer.ts index e57f4964757..dbd6604ff64 100644 --- a/src/demux/mp4demuxer.ts +++ b/src/demux/mp4demuxer.ts @@ -60,7 +60,7 @@ class MP4Demuxer implements Demuxer { } demuxSampleAes (data: Uint8Array, decryptData: Uint8Array, timeOffset: number): Promise { - return Promise.reject(new Error('The MP4 demuxer does not support SAMPLE-AES decryption')); + return Promise.reject(new Error('The MP4 demuxer does not support SAMPLE-AES decryption, Please specify DRM')); } destroy () {} diff --git a/src/demux/transmuxer.ts b/src/demux/transmuxer.ts index b3e59f8184d..7b5aa4a0877 100644 --- a/src/demux/transmuxer.ts +++ b/src/demux/transmuxer.ts @@ -14,7 +14,7 @@ import ChunkCache from './chunk-cache'; import { appendUint8Array } from '../utils/mp4-tools'; import { logger } from '../utils/logger'; -import { HlsConfig } from '../config'; +import { HlsConfig, KeyidValue } from '../config'; let now; // performance.now() not available on WebWorker, at least on Safari Desktop @@ -56,12 +56,18 @@ export default class Transmuxer { private transmuxConfig!: TransmuxConfig; private currentTransmuxState!: TransmuxState; private cache: ChunkCache = new ChunkCache(); + private emeEnabled: boolean; + private clearkeyServerUrl?: string; + private clearkeyPair: KeyidValue | null; constructor (observer: HlsEventEmitter, typeSupported, config: HlsConfig, vendor) { this.observer = observer; this.typeSupported = typeSupported; this.config = config; this.vendor = vendor; + this.emeEnabled = this.config.emeEnabled; + this.clearkeyServerUrl = this.config.clearkeyServerUrl; + this.clearkeyPair = this.config.clearkeyPair; } configure (transmuxConfig: TransmuxConfig, state: TransmuxState) { @@ -246,7 +252,13 @@ export default class Transmuxer { private transmux (data: Uint8Array, decryptData: Uint8Array, encryptionType: string | null, timeOffset: number, accurateTimeOffset: boolean, chunkMeta: ChunkMetadata): TransmuxerResult | Promise { let result: TransmuxerResult | Promise; if (encryptionType === 'SAMPLE-AES') { - result = this.transmuxSampleAes(data, decryptData, timeOffset, accurateTimeOffset, chunkMeta); + // May Get the URI from the manifest + logger.log('Check if the clearkeyPair or clearkeyServerUrl is specified to play SAMPLE-AES cbcs fMP4'); + if (this.emeEnabled && (this.clearkeyPair || this.clearkeyServerUrl)) { + result = this.transmuxUnencrypted(data, timeOffset, accurateTimeOffset, chunkMeta); + } else { + result = this.transmuxSampleAes(data, decryptData, timeOffset, accurateTimeOffset, chunkMeta); + } } else { result = this.transmuxUnencrypted(data, timeOffset, accurateTimeOffset, chunkMeta); } diff --git a/src/utils/mediakeys-helper.ts b/src/utils/mediakeys-helper.ts index 69c5929592c..ede2c5706fe 100644 --- a/src/utils/mediakeys-helper.ts +++ b/src/utils/mediakeys-helper.ts @@ -4,6 +4,7 @@ export enum KeySystems { WIDEVINE = 'com.widevine.alpha', PLAYREADY = 'com.microsoft.playready', + CLEARKEY = 'org.w3.clearkey' } export type MediaKeyFunc = (keySystem: KeySystems, supportedConfigurations: MediaKeySystemConfiguration[]) => Promise; diff --git a/tests/unit/controller/eme-controller.js b/tests/unit/controller/eme-controller.js index f55db79e4c4..e03fb9f859b 100644 --- a/tests/unit/controller/eme-controller.js +++ b/tests/unit/controller/eme-controller.js @@ -90,6 +90,8 @@ describe('EMEController', function () { audioRobustness: 'HW_SECURE_ALL', videoRobustness: 'HW_SECURE_ALL' }, + clearkeyPair: null, + clearkeyServerUrl: void 0, requestMediaKeySystemAccessFunc: reqMediaKsAccessSpy });