Source: lib/dash/content_protection.js

/**
 * @license
 * Copyright 2016 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

goog.provide('shaka.dash.ContentProtection');

goog.require('goog.asserts');
goog.require('shaka.util.Error');
goog.require('shaka.util.Functional');
goog.require('shaka.util.MapUtils');
goog.require('shaka.util.XmlUtils');


/**
 * @namespace shaka.dash.ContentProtection
 * @summary A set of functions for parsing and interpreting ContentProtection
 *   elements.
 */


/**
 * @typedef {{
 *   defaultKeyId: ?string,
 *   defaultInit: Array.<shakaExtern.InitDataOverride>,
 *   drmInfos: !Array.<shakaExtern.DrmInfo>,
 *   firstRepresentation: boolean
 * }}
 *
 * @description
 * Contains information about the ContentProtection elements found at the
 * AdaptationSet level.
 *
 * @property {?string} defaultKeyId
 *   The default key ID to use.  This is used by parseKeyIds as a default.  This
 *   can be null to indicate that there is no default.
 * @property {Array.<shakaExtern.InitDataOverride>} defaultInit
 *   The default init data override.  This can be null to indicate that there
 *   is no default.
 * @property {!Array.<shakaExtern.DrmInfo>} drmInfos
 *   The DrmInfo objects.
 * @property {boolean} firstRepresentation
 *   True when first parsed; changed to false after the first call to
 *   parseKeyIds.  This is used to determine if a dummy key-system should be
 *   overwritten; namely that the first representation can replace the dummy
 *   from the AdaptationSet.
 */
shaka.dash.ContentProtection.Context;


/**
 * @typedef {{
 *   node: !Element,
 *   schemeUri: string,
 *   keyId: ?string,
 *   init: Array.<shakaExtern.InitDataOverride>
 * }}
 *
 * @description
 * The parsed result of a single ContentProtection element.
 *
 * @property {!Element} node
 *   The ContentProtection XML element.
 * @property {string} schemeUri
 *   The scheme URI.
 * @property {?string} keyId
 *   The default key ID, if present.
 * @property {Array.<shakaExtern.InitDataOverride>} init
 *   The init data, if present.  If there is no init data, it will be null.  If
 *   this is non-null, there is at least one element.
 */
shaka.dash.ContentProtection.Element;


/**
 * A map of scheme URI to key system name.
 *
 * @const {!Object.<string, string>}
 * @private
 */
shaka.dash.ContentProtection.defaultKeySystems_ = {
  'urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b': 'org.w3.clearkey',
  'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed': 'com.widevine.alpha',
  'urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95': 'com.microsoft.playready',
  'urn:uuid:f239e769-efa3-4850-9c16-a903c6932efb': 'com.adobe.primetime'
};


/**
 * @const {string}
 * @private
 */
shaka.dash.ContentProtection.MP4Protection_ =
    'urn:mpeg:dash:mp4protection:2011';


/**
 * Parses info from the ContentProtection elements at the AdaptationSet level.
 *
 * @param {!Array.<!Element>} elems
 * @param {shakaExtern.DashContentProtectionCallback} callback
 * @return {shaka.dash.ContentProtection.Context}
 */
shaka.dash.ContentProtection.parseFromAdaptationSet = function(
    elems, callback) {
  var ContentProtection = shaka.dash.ContentProtection;
  var Functional = shaka.util.Functional;
  var MapUtils = shaka.util.MapUtils;
  var parsed = ContentProtection.parseElements_(elems);

  // Find the default key ID and init data.  Create a new array of all the
  // non-CENC elements.
  /** @type {Array.<shakaExtern.InitDataOverride>} */
  var defaultInit = null;
  var parsedNonCenc = parsed.filter(function(elem) {
    if (elem.schemeUri == ContentProtection.MP4Protection_) {
      goog.asserts.assert(!elem.init || elem.init.length,
                          'Init data must be null or non-empty.');
      defaultInit = elem.init || defaultInit;
      return false;
    } else {
      return true;
    }
  });

  // Get the default key ID; if there are multiple, they must all match.
  var keyIds = parsed.map(function(elem) { return elem.keyId; })
    .filter(Functional.isNotNull);
  /** @type {?string} */
  var defaultKeyId = null;
  if (keyIds.length > 0) {
    defaultKeyId = keyIds[0];
    if (keyIds.some(Functional.isNotEqualFunc(defaultKeyId))) {
      throw new shaka.util.Error(
          shaka.util.Error.Category.MANIFEST,
          shaka.util.Error.Code.DASH_CONFLICTING_KEY_IDS);
    }
  }

  /** @type {!Array.<shakaExtern.DrmInfo>} */
  var drmInfos = [];
  if (parsedNonCenc.length > 0) {
    drmInfos = ContentProtection.convertElements_(
        defaultInit, callback, parsedNonCenc);

    // If there are no drmInfos after parsing, then add a dummy entry.  This may
    // be removed in parseKeyIds.
    if (drmInfos.length == 0) {
      drmInfos = [ContentProtection.createDrmInfo_('', defaultInit)];
    }
  } else if (parsed.length > 0) {
    // If there are only CENC element(s), then assume all key-systems are
    // supported.
    var keySystems = ContentProtection.defaultKeySystems_;
    drmInfos =
        MapUtils.values(keySystems)
            .map(function(keySystem) {
              return ContentProtection.createDrmInfo_(keySystem, defaultInit);
            });
  }

  return {
    defaultKeyId: defaultKeyId,
    defaultInit: defaultInit,
    drmInfos: drmInfos,
    firstRepresentation: true
  };
};


/**
 * Parses the given ContentProtection elements found at the Representation
 * level.  This may update the |context|.
 *
 * @param {!Array.<!Element>} elems
 * @param {shakaExtern.DashContentProtectionCallback} callback
 * @param {shaka.dash.ContentProtection.Context} context
 * @return {?string} The parsed key ID
 */
shaka.dash.ContentProtection.parseFromRepresentation = function(
    elems, callback, context) {
  var ContentProtection = shaka.dash.ContentProtection;
  var repContext = ContentProtection.parseFromAdaptationSet(elems, callback);

  if (context.firstRepresentation) {
    var asUnknown = context.drmInfos.length == 1 &&
        !context.drmInfos[0].keySystem;
    var asUnencrypted = context.drmInfos.length == 0;
    var repUnencrypted = repContext.drmInfos.length == 0;

    // There are two cases when we need to replace the |drmInfos| in the context
    // with those in the Representation:
    // * The AdaptationSet does not list any ContentProtection.
    // * The AdaptationSet only lists unknown key-systems.
    if (asUnencrypted || (asUnknown && !repUnencrypted)) {
      context.drmInfos = repContext.drmInfos;
    }
    context.firstRepresentation = false;
  } else if (repContext.drmInfos.length > 0) {
    // If this is not the first Representation, then we need to remove entries
    // from the context that do not appear in this Representation.
    context.drmInfos = context.drmInfos.filter(function(asInfo) {
      return repContext.drmInfos.some(function(repInfo) {
        return repInfo.keySystem == asInfo.keySystem;
      });
    });
    // If we have filtered out all key-systems, throw an error.
    if (context.drmInfos.length == 0) {
      throw new shaka.util.Error(
          shaka.util.Error.Category.MANIFEST,
          shaka.util.Error.Code.DASH_NO_COMMON_KEY_SYSTEM);
    }
  }

  return repContext.defaultKeyId || context.defaultKeyId;
};


/**
 * Creates a DrmInfo object from the given info.
 *
 * @param {string} keySystem
 * @param {Array.<shakaExtern.InitDataOverride>} initData
 * @return {shakaExtern.DrmInfo}
 * @private
 */
shaka.dash.ContentProtection.createDrmInfo_ = function(keySystem, initData) {
  return {
    keySystem: keySystem,
    licenseServerUri: '',
    distinctiveIdentifierRequired: false,
    persistentStateRequired: false,
    audioRobustness: '',
    videoRobustness: '',
    serverCertificate: null,
    initData: initData || []
  };
};


/**
 * Creates DrmInfo objects from the given element.
 *
 * @param {Array.<shakaExtern.InitDataOverride>} defaultInit
 * @param {shakaExtern.DashContentProtectionCallback} callback
 * @param {!Array.<shaka.dash.ContentProtection.Element>} elements
 * @return {!Array.<shakaExtern.DrmInfo>}
 * @private
 */
shaka.dash.ContentProtection.convertElements_ = function(
    defaultInit, callback, elements) {
  var Functional = shaka.util.Functional;
  return elements.map(
      /**
       * @param {shaka.dash.ContentProtection.Element} element
       * @return {!Array.<shakaExtern.DrmInfo>}
       */
      function(element) {
        var ContentProtection = shaka.dash.ContentProtection;
        var keySystem = ContentProtection.defaultKeySystems_[element.schemeUri];
        if (keySystem) {
          goog.asserts.assert(!element.init || element.init.length,
                              'Init data must be null or non-empty.');
          var initData = element.init || defaultInit;
          return [ContentProtection.createDrmInfo_(keySystem, initData)];
        } else {
          goog.asserts.assert(
              callback, 'ContentProtection callback is required');
          return callback(element.node) || [];
        }
      }).reduce(Functional.collapseArrays, []);
};


/**
 * Parses the given ContentProtection elements.  If there is an error, it
 * removes those elements.
 *
 * @param {!Array.<!Element>} elems
 * @return {!Array.<shaka.dash.ContentProtection.Element>}
 * @private
 */
shaka.dash.ContentProtection.parseElements_ = function(elems) {
  var Functional = shaka.util.Functional;
  return elems.map(
      /**
       * @param {!Element} elem
       * @return {?shaka.dash.ContentProtection.Element}
       */
      function(elem) {
        /** @type {?string} */
        var schemeUri = elem.getAttribute('schemeIdUri');
        /** @type {?string} */
        var keyId = elem.getAttribute('cenc:default_KID');
        /** @type {!Array.<string>} */
        var psshs = shaka.util.XmlUtils.findChildren(elem, 'cenc:pssh')
                        .map(shaka.util.XmlUtils.getContents);

        if (!schemeUri) {
          shaka.log.error('Missing required schemeIdUri attribute on',
                          'ContentProtection element', elem);
          return null;
        }

        schemeUri = schemeUri.toLowerCase();
        if (keyId) {
          keyId = keyId.replace(/-/g, '').toLowerCase();
          if (keyId.indexOf(' ') >= 0) {
            throw new shaka.util.Error(
                shaka.util.Error.Category.MANIFEST,
                shaka.util.Error.Code.DASH_MULTIPLE_KEY_IDS_NOT_SUPPORTED);
          }
        }

        /** @type {!Array.<shakaExtern.InitDataOverride>} */
        var init = [];
        try {
          init = psshs.map(function(pssh) {
            /** @type {shakaExtern.InitDataOverride} */
            var ret = {
              initDataType: 'cenc',
              initData: shaka.util.Uint8ArrayUtils.fromBase64(pssh)
            };
            return ret;
          });
        } catch (e) {
          // Invalid PSSH data, ignore.
          throw new shaka.util.Error(
              shaka.util.Error.Category.MANIFEST,
              shaka.util.Error.Code.DASH_PSSH_BAD_ENCODING);
        }

        /** @type {shaka.dash.ContentProtection.Element} */
        var element = {
          node: elem,
          schemeUri: schemeUri,
          keyId: keyId,
          init: (init.length > 0 ? init : null)
        };
        return element;
      }).filter(Functional.isNotNull);
};