Home Reference Source

src/controller/eme-controller.ts

  1. /**
  2. * @author Stephan Hesse <disparat@gmail.com> | <tchakabam@gmail.com>
  3. *
  4. * DRM support for Hls.js
  5. */
  6.  
  7. import EventHandler from '../event-handler';
  8. import Event from '../events';
  9. import { ErrorTypes, ErrorDetails } from '../errors';
  10.  
  11. import { logger } from '../utils/logger';
  12. import { EMEControllerConfig } from '../config';
  13. import { KeySystems, MediaKeyFunc } from '../utils/mediakeys-helper';
  14.  
  15. const MAX_LICENSE_REQUEST_FAILURES = 3;
  16.  
  17. /**
  18. * @see https://developer.mozilla.org/en-US/docs/Web/API/MediaKeySystemConfiguration
  19. * @param {Array<string>} audioCodecs List of required audio codecs to support
  20. * @param {Array<string>} videoCodecs List of required video codecs to support
  21. * @param {object} drmSystemOptions Optional parameters/requirements for the key-system
  22. * @returns {Array<MediaSystemConfiguration>} An array of supported configurations
  23. */
  24.  
  25. const createWidevineMediaKeySystemConfigurations = function (audioCodecs: string[], videoCodecs: string[]): MediaKeySystemConfiguration[] { /* jshint ignore:line */
  26. const baseConfig: MediaKeySystemConfiguration = {
  27. // initDataTypes: ['keyids', 'mp4'],
  28. // label: "",
  29. // persistentState: "not-allowed", // or "required" ?
  30. // distinctiveIdentifier: "not-allowed", // or "required" ?
  31. // sessionTypes: ['temporary'],
  32. videoCapabilities: [] // { contentType: 'video/mp4; codecs="avc1.42E01E"' }
  33. };
  34.  
  35. videoCodecs.forEach((codec) => {
  36. baseConfig.videoCapabilities!.push({
  37. contentType: `video/mp4; codecs="${codec}"`
  38. });
  39. });
  40.  
  41. return [
  42. baseConfig
  43. ];
  44. };
  45.  
  46. /**
  47. * The idea here is to handle key-system (and their respective platforms) specific configuration differences
  48. * in order to work with the local requestMediaKeySystemAccess method.
  49. *
  50. * We can also rule-out platform-related key-system support at this point by throwing an error.
  51. *
  52. * @param {string} keySystem Identifier for the key-system, see `KeySystems` enum
  53. * @param {Array<string>} audioCodecs List of required audio codecs to support
  54. * @param {Array<string>} videoCodecs List of required video codecs to support
  55. * @throws will throw an error if a unknown key system is passed
  56. * @returns {Array<MediaSystemConfiguration>} A non-empty Array of MediaKeySystemConfiguration objects
  57. */
  58. const getSupportedMediaKeySystemConfigurations = function (keySystem: KeySystems, audioCodecs: string[], videoCodecs: string[]): MediaKeySystemConfiguration[] {
  59. switch (keySystem) {
  60. case KeySystems.WIDEVINE:
  61. return createWidevineMediaKeySystemConfigurations(audioCodecs, videoCodecs);
  62. default:
  63. throw new Error(`Unknown key-system: ${keySystem}`);
  64. }
  65. };
  66.  
  67. interface MediaKeysListItem {
  68. mediaKeys?: MediaKeys,
  69. mediaKeysSession?: MediaKeySession,
  70. mediaKeysSessionInitialized: boolean;
  71. mediaKeySystemAccess: MediaKeySystemAccess;
  72. mediaKeySystemDomain: KeySystems;
  73. }
  74.  
  75. /**
  76. * Controller to deal with encrypted media extensions (EME)
  77. * @see https://developer.mozilla.org/en-US/docs/Web/API/Encrypted_Media_Extensions_API
  78. *
  79. * @class
  80. * @constructor
  81. */
  82. class EMEController extends EventHandler {
  83. private _widevineLicenseUrl?: string;
  84. private _licenseXhrSetup?: (xhr: XMLHttpRequest, url: string) => void;
  85. private _emeEnabled: boolean;
  86. private _requestMediaKeySystemAccess: MediaKeyFunc | null;
  87.  
  88. private _config: EMEControllerConfig;
  89. private _mediaKeysList: MediaKeysListItem[] = [];
  90. private _media: HTMLMediaElement | null = null;
  91. private _hasSetMediaKeys: boolean = false;
  92. private _requestLicenseFailureCount: number = 0;
  93.  
  94. private mediaKeysPromise: Promise<MediaKeys> | null = null;
  95.  
  96. /**
  97. * @constructs
  98. * @param {Hls} hls Our Hls.js instance
  99. */
  100. constructor (hls) {
  101. super(hls,
  102. Event.MEDIA_ATTACHED,
  103. Event.MEDIA_DETACHED,
  104. Event.MANIFEST_PARSED
  105. );
  106. this._config = hls.config;
  107.  
  108. this._widevineLicenseUrl = this._config.widevineLicenseUrl;
  109. this._licenseXhrSetup = this._config.licenseXhrSetup;
  110. this._emeEnabled = this._config.emeEnabled;
  111. this._requestMediaKeySystemAccess = this._config.requestMediaKeySystemAccessFunc;
  112. }
  113.  
  114. /**
  115. * @param {string} keySystem Identifier for the key-system, see `KeySystems` enum
  116. * @returns {string} License server URL for key-system (if any configured, otherwise causes error)
  117. * @throws if a unsupported keysystem is passed
  118. */
  119. getLicenseServerUrl (keySystem: KeySystems): string {
  120. switch (keySystem) {
  121. case KeySystems.WIDEVINE:
  122. if (!this._widevineLicenseUrl) {
  123. break;
  124. }
  125. return this._widevineLicenseUrl;
  126. }
  127.  
  128. throw new Error(`no license server URL configured for key-system "${keySystem}"`);
  129. }
  130.  
  131. /**
  132. * Requests access object and adds it to our list upon success
  133. * @private
  134. * @param {string} keySystem System ID (see `KeySystems`)
  135. * @param {Array<string>} audioCodecs List of required audio codecs to support
  136. * @param {Array<string>} videoCodecs List of required video codecs to support
  137. * @throws When a unsupported KeySystem is passed
  138. */
  139. private _attemptKeySystemAccess (keySystem: KeySystems, audioCodecs: string[], videoCodecs: string[]) {
  140. // TODO: add other DRM "options"
  141.  
  142. // This can throw, but is caught in event handler callpath
  143. const mediaKeySystemConfigs = getSupportedMediaKeySystemConfigurations(keySystem, audioCodecs, videoCodecs);
  144.  
  145. logger.log('Requesting encrypted media key-system access');
  146.  
  147. // expecting interface like window.navigator.requestMediaKeySystemAccess
  148. const keySystemAccessPromise = this.requestMediaKeySystemAccess(keySystem, mediaKeySystemConfigs);
  149.  
  150. this.mediaKeysPromise = keySystemAccessPromise.then((mediaKeySystemAccess) =>
  151. this._onMediaKeySystemAccessObtained(keySystem, mediaKeySystemAccess));
  152.  
  153. keySystemAccessPromise.catch((err) => {
  154. logger.error(`Failed to obtain key-system "${keySystem}" access:`, err);
  155. });
  156. }
  157.  
  158. get requestMediaKeySystemAccess () {
  159. if (!this._requestMediaKeySystemAccess) {
  160. throw new Error('No requestMediaKeySystemAccess function configured');
  161. }
  162.  
  163. return this._requestMediaKeySystemAccess;
  164. }
  165.  
  166. /**
  167. * Handles obtaining access to a key-system
  168. * @private
  169. * @param {string} keySystem
  170. * @param {MediaKeySystemAccess} mediaKeySystemAccess https://developer.mozilla.org/en-US/docs/Web/API/MediaKeySystemAccess
  171. */
  172. private _onMediaKeySystemAccessObtained (keySystem: KeySystems, mediaKeySystemAccess: MediaKeySystemAccess): Promise<MediaKeys> {
  173. logger.log(`Access for key-system "${keySystem}" obtained`);
  174.  
  175. const mediaKeysListItem: MediaKeysListItem = {
  176. mediaKeysSessionInitialized: false,
  177. mediaKeySystemAccess: mediaKeySystemAccess,
  178. mediaKeySystemDomain: keySystem
  179. };
  180.  
  181. this._mediaKeysList.push(mediaKeysListItem);
  182.  
  183. const mediaKeysPromise = Promise.resolve().then(() => mediaKeySystemAccess.createMediaKeys())
  184. .then((mediaKeys) => {
  185. mediaKeysListItem.mediaKeys = mediaKeys;
  186.  
  187. logger.log(`Media-keys created for key-system "${keySystem}"`);
  188.  
  189. this._onMediaKeysCreated();
  190.  
  191. return mediaKeys;
  192. });
  193.  
  194. mediaKeysPromise.catch((err) => {
  195. logger.error('Failed to create media-keys:', err);
  196. });
  197.  
  198. return mediaKeysPromise;
  199. }
  200.  
  201. /**
  202. * Handles key-creation (represents access to CDM). We are going to create key-sessions upon this
  203. * for all existing keys where no session exists yet.
  204. *
  205. * @private
  206. */
  207. private _onMediaKeysCreated () {
  208. // check for all key-list items if a session exists, otherwise, create one
  209. this._mediaKeysList.forEach((mediaKeysListItem) => {
  210. if (!mediaKeysListItem.mediaKeysSession) {
  211. // mediaKeys is definitely initialized here
  212. mediaKeysListItem.mediaKeysSession = mediaKeysListItem.mediaKeys!.createSession();
  213. this._onNewMediaKeySession(mediaKeysListItem.mediaKeysSession);
  214. }
  215. });
  216. }
  217.  
  218. /**
  219. * @private
  220. * @param {*} keySession
  221. */
  222. private _onNewMediaKeySession (keySession: MediaKeySession) {
  223. logger.log(`New key-system session ${keySession.sessionId}`);
  224.  
  225. keySession.addEventListener('message', (event: MediaKeyMessageEvent) => {
  226. this._onKeySessionMessage(keySession, event.message);
  227. }, false);
  228. }
  229.  
  230. /**
  231. * @private
  232. * @param {MediaKeySession} keySession
  233. * @param {ArrayBuffer} message
  234. */
  235. private _onKeySessionMessage (keySession: MediaKeySession, message: ArrayBuffer) {
  236. logger.log('Got EME message event, creating license request');
  237.  
  238. this._requestLicense(message, (data: ArrayBuffer) => {
  239. logger.log(`Received license data (length: ${data ? data.byteLength : data}), updating key-session`);
  240. keySession.update(data);
  241. });
  242. }
  243.  
  244. /**
  245. * @private
  246. * @param e {MediaEncryptedEvent}
  247. */
  248. private _onMediaEncrypted = (e: MediaEncryptedEvent) => {
  249. logger.log(`Media is encrypted using "${e.initDataType}" init data type`);
  250.  
  251. if (!this.mediaKeysPromise) {
  252. logger.error('Fatal: Media is encrypted but no CDM access or no keys have been requested');
  253. this.hls.trigger(Event.ERROR, {
  254. type: ErrorTypes.KEY_SYSTEM_ERROR,
  255. details: ErrorDetails.KEY_SYSTEM_NO_KEYS,
  256. fatal: true
  257. });
  258. return;
  259. }
  260.  
  261. const finallySetKeyAndStartSession = (mediaKeys) => {
  262. if (!this._media) {
  263. return;
  264. }
  265. this._attemptSetMediaKeys(mediaKeys);
  266. this._generateRequestWithPreferredKeySession(e.initDataType, e.initData);
  267. };
  268.  
  269. // Could use `Promise.finally` but some Promise polyfills are missing it
  270. this.mediaKeysPromise.then(finallySetKeyAndStartSession).catch(finallySetKeyAndStartSession);
  271. }
  272.  
  273. /**
  274. * @private
  275. */
  276. private _attemptSetMediaKeys (mediaKeys?: MediaKeys) {
  277. if (!this._media) {
  278. throw new Error('Attempted to set mediaKeys without first attaching a media element');
  279. }
  280.  
  281. if (!this._hasSetMediaKeys) {
  282. // FIXME: see if we can/want/need-to really to deal with several potential key-sessions?
  283. const keysListItem = this._mediaKeysList[0];
  284. if (!keysListItem || !keysListItem.mediaKeys) {
  285. logger.error('Fatal: Media is encrypted but no CDM access or no keys have been obtained yet');
  286. this.hls.trigger(Event.ERROR, {
  287. type: ErrorTypes.KEY_SYSTEM_ERROR,
  288. details: ErrorDetails.KEY_SYSTEM_NO_KEYS,
  289. fatal: true
  290. });
  291. return;
  292. }
  293.  
  294. logger.log('Setting keys for encrypted media');
  295.  
  296. this._media.setMediaKeys(keysListItem.mediaKeys);
  297. this._hasSetMediaKeys = true;
  298. }
  299. }
  300.  
  301. /**
  302. * @private
  303. */
  304. private _generateRequestWithPreferredKeySession (initDataType: string, initData: ArrayBuffer | null) {
  305. // FIXME: see if we can/want/need-to really to deal with several potential key-sessions?
  306. const keysListItem = this._mediaKeysList[0];
  307. if (!keysListItem) {
  308. logger.error('Fatal: Media is encrypted but not any key-system access has been obtained yet');
  309. this.hls.trigger(Event.ERROR, {
  310. type: ErrorTypes.KEY_SYSTEM_ERROR,
  311. details: ErrorDetails.KEY_SYSTEM_NO_ACCESS,
  312. fatal: true
  313. });
  314. return;
  315. }
  316.  
  317. if (keysListItem.mediaKeysSessionInitialized) {
  318. logger.warn('Key-Session already initialized but requested again');
  319. return;
  320. }
  321.  
  322. const keySession = keysListItem.mediaKeysSession;
  323. if (!keySession) {
  324. logger.error('Fatal: Media is encrypted but no key-session existing');
  325. this.hls.trigger(Event.ERROR, {
  326. type: ErrorTypes.KEY_SYSTEM_ERROR,
  327. details: ErrorDetails.KEY_SYSTEM_NO_SESSION,
  328. fatal: true
  329. });
  330. return;
  331. }
  332.  
  333. // initData is null if the media is not CORS-same-origin
  334. if (!initData) {
  335. logger.warn('Fatal: initData required for generating a key session is null');
  336. this.hls.trigger(Event.ERROR, {
  337. type: ErrorTypes.KEY_SYSTEM_ERROR,
  338. details: ErrorDetails.KEY_SYSTEM_NO_INIT_DATA,
  339. fatal: true
  340. });
  341. return;
  342. }
  343.  
  344. logger.log(`Generating key-session request for "${initDataType}" init data type`);
  345. keysListItem.mediaKeysSessionInitialized = true;
  346.  
  347. keySession.generateRequest(initDataType, initData)
  348. .then(() => {
  349. logger.debug('Key-session generation succeeded');
  350. })
  351. .catch((err) => {
  352. logger.error('Error generating key-session request:', err);
  353. this.hls.trigger(Event.ERROR, {
  354. type: ErrorTypes.KEY_SYSTEM_ERROR,
  355. details: ErrorDetails.KEY_SYSTEM_NO_SESSION,
  356. fatal: false
  357. });
  358. });
  359. }
  360.  
  361. /**
  362. * @private
  363. * @param {string} url License server URL
  364. * @param {ArrayBuffer} keyMessage Message data issued by key-system
  365. * @param {function} callback Called when XHR has succeeded
  366. * @returns {XMLHttpRequest} Unsent (but opened state) XHR object
  367. * @throws if XMLHttpRequest construction failed
  368. */
  369. private _createLicenseXhr (url: string, keyMessage: ArrayBuffer, callback: (data: ArrayBuffer) => void): XMLHttpRequest {
  370. const xhr = new XMLHttpRequest();
  371. const licenseXhrSetup = this._licenseXhrSetup;
  372.  
  373. try {
  374. if (licenseXhrSetup) {
  375. try {
  376. licenseXhrSetup(xhr, url);
  377. } catch (e) {
  378. // let's try to open before running setup
  379. xhr.open('POST', url, true);
  380. licenseXhrSetup(xhr, url);
  381. }
  382. }
  383. // if licenseXhrSetup did not yet call open, let's do it now
  384. if (!xhr.readyState) {
  385. xhr.open('POST', url, true);
  386. }
  387. } catch (e) {
  388. // IE11 throws an exception on xhr.open if attempting to access an HTTP resource over HTTPS
  389. throw new Error(`issue setting up KeySystem license XHR ${e}`);
  390. }
  391.  
  392. // Because we set responseType to ArrayBuffer here, callback is typed as handling only array buffers
  393. xhr.responseType = 'arraybuffer';
  394. xhr.onreadystatechange =
  395. this._onLicenseRequestReadyStageChange.bind(this, xhr, url, keyMessage, callback);
  396. return xhr;
  397. }
  398.  
  399. /**
  400. * @private
  401. * @param {XMLHttpRequest} xhr
  402. * @param {string} url License server URL
  403. * @param {ArrayBuffer} keyMessage Message data issued by key-system
  404. * @param {function} callback Called when XHR has succeeded
  405. */
  406. private _onLicenseRequestReadyStageChange (xhr: XMLHttpRequest, url: string, keyMessage: ArrayBuffer, callback: (data: ArrayBuffer) => void) {
  407. switch (xhr.readyState) {
  408. case 4:
  409. if (xhr.status === 200) {
  410. this._requestLicenseFailureCount = 0;
  411. logger.log('License request succeeded');
  412.  
  413. if (xhr.responseType !== 'arraybuffer') {
  414. logger.warn('xhr response type was not set to the expected arraybuffer for license request');
  415. }
  416. callback(xhr.response);
  417. } else {
  418. logger.error(`License Request XHR failed (${url}). Status: ${xhr.status} (${xhr.statusText})`);
  419. this._requestLicenseFailureCount++;
  420. if (this._requestLicenseFailureCount > MAX_LICENSE_REQUEST_FAILURES) {
  421. this.hls.trigger(Event.ERROR, {
  422. type: ErrorTypes.KEY_SYSTEM_ERROR,
  423. details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED,
  424. fatal: true
  425. });
  426. return;
  427. }
  428.  
  429. const attemptsLeft = MAX_LICENSE_REQUEST_FAILURES - this._requestLicenseFailureCount + 1;
  430. logger.warn(`Retrying license request, ${attemptsLeft} attempts left`);
  431. this._requestLicense(keyMessage, callback);
  432. }
  433. break;
  434. }
  435. }
  436.  
  437. /**
  438. * @private
  439. * @param {MediaKeysListItem} keysListItem
  440. * @param {ArrayBuffer} keyMessage
  441. * @returns {ArrayBuffer} Challenge data posted to license server
  442. * @throws if KeySystem is unsupported
  443. */
  444. private _generateLicenseRequestChallenge (keysListItem: MediaKeysListItem, keyMessage: ArrayBuffer): ArrayBuffer {
  445. switch (keysListItem.mediaKeySystemDomain) {
  446. // case KeySystems.PLAYREADY:
  447. // from https://github.com/MicrosoftEdge/Demos/blob/master/eme/scripts/demo.js
  448. /*
  449. if (this.licenseType !== this.LICENSE_TYPE_WIDEVINE) {
  450. // For PlayReady CDMs, we need to dig the Challenge out of the XML.
  451. var keyMessageXml = new DOMParser().parseFromString(String.fromCharCode.apply(null, new Uint16Array(keyMessage)), 'application/xml');
  452. if (keyMessageXml.getElementsByTagName('Challenge')[0]) {
  453. challenge = atob(keyMessageXml.getElementsByTagName('Challenge')[0].childNodes[0].nodeValue);
  454. } else {
  455. throw 'Cannot find <Challenge> in key message';
  456. }
  457. var headerNames = keyMessageXml.getElementsByTagName('name');
  458. var headerValues = keyMessageXml.getElementsByTagName('value');
  459. if (headerNames.length !== headerValues.length) {
  460. throw 'Mismatched header <name>/<value> pair in key message';
  461. }
  462. for (var i = 0; i < headerNames.length; i++) {
  463. xhr.setRequestHeader(headerNames[i].childNodes[0].nodeValue, headerValues[i].childNodes[0].nodeValue);
  464. }
  465. }
  466. break;
  467. */
  468. case KeySystems.WIDEVINE:
  469. // For Widevine CDMs, the challenge is the keyMessage.
  470. return keyMessage;
  471. }
  472.  
  473. throw new Error(`unsupported key-system: ${keysListItem.mediaKeySystemDomain}`);
  474. }
  475.  
  476. /**
  477. * @private
  478. * @param keyMessage
  479. * @param callback
  480. */
  481. private _requestLicense (keyMessage: ArrayBuffer, callback: (data: ArrayBuffer) => void) {
  482. logger.log('Requesting content license for key-system');
  483.  
  484. const keysListItem = this._mediaKeysList[0];
  485. if (!keysListItem) {
  486. logger.error('Fatal error: Media is encrypted but no key-system access has been obtained yet');
  487. this.hls.trigger(Event.ERROR, {
  488. type: ErrorTypes.KEY_SYSTEM_ERROR,
  489. details: ErrorDetails.KEY_SYSTEM_NO_ACCESS,
  490. fatal: true
  491. });
  492. return;
  493. }
  494.  
  495. try {
  496. const url = this.getLicenseServerUrl(keysListItem.mediaKeySystemDomain);
  497. const xhr = this._createLicenseXhr(url, keyMessage, callback);
  498. logger.log(`Sending license request to URL: ${url}`);
  499. const challenge = this._generateLicenseRequestChallenge(keysListItem, keyMessage);
  500. xhr.send(challenge);
  501. } catch (e) {
  502. logger.error(`Failure requesting DRM license: ${e}`);
  503. this.hls.trigger(Event.ERROR, {
  504. type: ErrorTypes.KEY_SYSTEM_ERROR,
  505. details: ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED,
  506. fatal: true
  507. });
  508. }
  509. }
  510.  
  511. onMediaAttached (data: { media: HTMLMediaElement; }) {
  512. if (!this._emeEnabled) {
  513. return;
  514. }
  515.  
  516. const media = data.media;
  517.  
  518. // keep reference of media
  519. this._media = media;
  520.  
  521. media.addEventListener('encrypted', this._onMediaEncrypted);
  522. }
  523.  
  524. onMediaDetached () {
  525. if (this._media) {
  526. this._media.removeEventListener('encrypted', this._onMediaEncrypted);
  527. this._media = null; // release reference
  528. }
  529. }
  530.  
  531. // TODO: Use manifest types here when they are defined
  532. onManifestParsed (data: any) {
  533. if (!this._emeEnabled) {
  534. return;
  535. }
  536.  
  537. const audioCodecs = data.levels.map((level) => level.audioCodec);
  538. const videoCodecs = data.levels.map((level) => level.videoCodec);
  539.  
  540. this._attemptKeySystemAccess(KeySystems.WIDEVINE, audioCodecs, videoCodecs);
  541. }
  542. }
  543.  
  544. export default EMEController;