UNPKG

shaka-player

Version:
753 lines (678 loc) 24.1 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.provide('shaka.dash.ContentProtection'); goog.require('goog.asserts'); goog.require('shaka.log'); goog.require('shaka.drm.DrmUtils'); goog.require('shaka.drm.PlayReady'); goog.require('shaka.util.Error'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.Pssh'); goog.require('shaka.util.StringUtils'); goog.require('shaka.util.TXml'); goog.require('shaka.util.Uint8ArrayUtils'); /** * @summary A set of functions for parsing and interpreting ContentProtection * elements. */ shaka.dash.ContentProtection = class { /** * Parses info from the ContentProtection elements at the AdaptationSet level. * * @param {!Array<!shaka.extern.xml.Node>} elements * @param {boolean} ignoreDrmInfo * @param {!Object<string, string>} keySystemsByURI * @return {shaka.dash.ContentProtection.Context} */ static parseFromAdaptationSet(elements, ignoreDrmInfo, keySystemsByURI) { const ContentProtection = shaka.dash.ContentProtection; const ManifestParserUtils = shaka.util.ManifestParserUtils; const parsed = ContentProtection.parseElements_(elements); /** @type {Array<shaka.extern.InitDataOverride>} */ let defaultInit = null; /** @type {!Array<shaka.extern.DrmInfo>} */ let drmInfos = []; let parsedNonCenc = []; /** @type {?shaka.dash.ContentProtection.Aes128Info} */ let aes128Info = null; // Get the default key ID; if there are multiple, they must all match. const keyIds = new Set(parsed.map((element) => element.keyId)); // Remove any possible null value (elements may have no key ids). keyIds.delete(null); let encryptionScheme = 'cenc'; if (keyIds.size > 1) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_CONFLICTING_KEY_IDS); } if (!ignoreDrmInfo) { const aes128Elements = parsed.filter((elem) => { return elem.schemeUri == ContentProtection.Aes128Protection_; }); if (aes128Elements.length > 1) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_CONFLICTING_AES_128); } if (aes128Elements.length) { aes128Info = ContentProtection.parseAes128_(aes128Elements[0]); } const mp4ProtectionParsed = parsed.find((elem) => { return elem.schemeUri == ContentProtection.MP4Protection_; }); if (mp4ProtectionParsed && mp4ProtectionParsed.encryptionScheme) { encryptionScheme = mp4ProtectionParsed.encryptionScheme; } // Find the default key ID and init data. Create a new array of all the // non-CENC elements. parsedNonCenc = parsed.filter((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 elem.schemeUri != ContentProtection.Aes128Protection_; } }); if (parsedNonCenc.length) { drmInfos = ContentProtection.convertElements_(defaultInit, encryptionScheme, parsedNonCenc, keySystemsByURI, keyIds); // If there are no drmInfos after parsing, then add a dummy entry. // This may be removed in parseKeyIds. if (drmInfos.length == 0) { drmInfos = [ManifestParserUtils.createDrmInfo( '', encryptionScheme, defaultInit)]; } } } // If there are only CENC element(s) or ignoreDrmInfo flag is set, assume // all key-systems are supported. if (parsed.length && !aes128Info && (ignoreDrmInfo || !parsedNonCenc.length)) { drmInfos = []; for (const keySystem of Object.values(keySystemsByURI)) { // If the manifest doesn't specify any key systems, we shouldn't // put clearkey in this list. Otherwise, it may be triggered when // a real key system should be used instead. if (keySystem != 'org.w3.clearkey') { const info = ManifestParserUtils.createDrmInfo( keySystem, encryptionScheme, defaultInit); drmInfos.push(info); } } } // If we have a default key id, apply it to every initData. const defaultKeyId = Array.from(keyIds)[0] || null; if (defaultKeyId) { for (const info of drmInfos) { for (const initData of info.initData) { initData.keyId = defaultKeyId; } } } return { defaultKeyId: defaultKeyId, defaultInit: defaultInit, drmInfos: drmInfos, aes128Info: aes128Info, firstRepresentation: true, }; } /** * Parses the given ContentProtection elements found at the Representation * level. This may update the |context|. * * @param {!Array<!shaka.extern.xml.Node>} elements * @param {shaka.dash.ContentProtection.Context} context * @param {boolean} ignoreDrmInfo * @param {!Object<string, string>} keySystemsByURI * @return {?string} The parsed key ID */ static parseFromRepresentation( elements, context, ignoreDrmInfo, keySystemsByURI) { const ContentProtection = shaka.dash.ContentProtection; const repContext = ContentProtection.parseFromAdaptationSet( elements, ignoreDrmInfo, keySystemsByURI); if (context.firstRepresentation) { const asUnknown = context.drmInfos.length == 1 && !context.drmInfos[0].keySystem; const asUnencrypted = context.drmInfos.length == 0; const repUnencrypted = repContext.drmInfos.length == 0; // There are two cases where we need to replace the |drmInfos| in the // context with those in the Representation: // 1. The AdaptationSet does not list any ContentProtection. // 2. 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((asInfo) => { return repContext.drmInfos.some((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.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_NO_COMMON_KEY_SYSTEM); } } return repContext.defaultKeyId || context.defaultKeyId; } /** * Gets a FairPlay license URL from a content protection element * containing a 'dashif:Laurl' element * * @param {shaka.dash.ContentProtection.Element} element * @return {string} */ static getFairPlayLicenseUrl(element) { if (shaka.drm.DrmUtils.isMediaKeysPolyfilled('apple')) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code .DASH_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED); } const dashIfLaurlNode = shaka.util.TXml.findChildNS( element.node, shaka.dash.ContentProtection.DashIfNamespaceUri_, 'Laurl', ); if (dashIfLaurlNode) { const textContents = shaka.util.TXml.getTextContents(dashIfLaurlNode); if (textContents) { return textContents; } } return ''; } /** * Gets a Widevine license URL from a content protection element * containing a custom `ms:laurl` or 'dashif:Laurl' elements * * @param {shaka.dash.ContentProtection.Element} element * @return {string} */ static getWidevineLicenseUrl(element) { const StringUtils = shaka.util.StringUtils; const dashIfLaurlNode = shaka.util.TXml.findChildNS( element.node, shaka.dash.ContentProtection.DashIfNamespaceUri_, 'Laurl', ); if (dashIfLaurlNode) { const textContents = shaka.util.TXml.getTextContents(dashIfLaurlNode); if (textContents) { return textContents; } } const msLaUrlNode = shaka.util.TXml.findChildNS( element.node, 'urn:microsoft', 'laurl'); if (msLaUrlNode) { return StringUtils.htmlUnescape( msLaUrlNode.attributes['licenseUrl']) || ''; } return ''; } /** * Gets a ClearKey license URL from a content protection element * containing a custom `clearkey::Laurl` or 'dashif:Laurl' elements * * @param {shaka.dash.ContentProtection.Element} element * @return {string} */ static getClearKeyLicenseUrl(element) { const dashIfLaurlNode = shaka.util.TXml.findChildNS( element.node, shaka.dash.ContentProtection.DashIfNamespaceUri_, 'Laurl', ); if (dashIfLaurlNode) { const textContents = shaka.util.TXml.getTextContents(dashIfLaurlNode); if (textContents) { return textContents; } } const clearKeyLaurlNode = shaka.util.TXml.findChildNS( element.node, shaka.dash.ContentProtection.ClearKeyNamespaceUri_, 'Laurl', ); if (clearKeyLaurlNode && clearKeyLaurlNode.attributes['Lic_type'] === 'EME-1.0') { if (clearKeyLaurlNode) { const textContents = shaka.util.TXml.getTextContents(clearKeyLaurlNode); if (textContents) { return textContents; } } } return ''; } /** * Gets a PlayReady license URL from a content protection element * containing a PlayReady Header Object * * @param {shaka.dash.ContentProtection.Element} element * @return {string} */ static getPlayReadyLicenseUrl(element) { const TXml = shaka.util.TXml; const dashIfLaurlNode = TXml.findChildNS( element.node, shaka.dash.ContentProtection.DashIfNamespaceUri_, 'Laurl', ); if (dashIfLaurlNode) { const textContents = TXml.getTextContents(dashIfLaurlNode); if (textContents) { return textContents; } } const proNode = TXml.findChildNS( element.node, 'urn:microsoft:playready', 'pro'); if (proNode) { const textContents = TXml.getTextContents(proNode); if (textContents) { return shaka.drm.PlayReady.getLicenseUrl(proNode); } } const psshNode = TXml.findChildNS( element.node, shaka.dash.ContentProtection.CencNamespaceUri_, 'pssh'); if (psshNode) { const textContents = TXml.getTextContents(psshNode); if (textContents) { const proData = shaka.util.Pssh.getPsshData( shaka.util.Uint8ArrayUtils.fromBase64(textContents)); const proString = shaka.util.Uint8ArrayUtils.toStandardBase64(proData); const reBuildProNode = TXml.parseXmlString('<pro>' + proString + '</pro>'); goog.asserts.assert(reBuildProNode, 'Must have pro node'); return shaka.drm.PlayReady.getLicenseUrl(reBuildProNode); } } return ''; } /** * Gets a PlayReady initData from a content protection element * containing a PlayReady Pro Object * * @param {shaka.dash.ContentProtection.Element} element * @return {?Array<shaka.extern.InitDataOverride>} * @private */ static getInitDataFromPro_(element) { const proNode = shaka.util.TXml.findChildNS( element.node, 'urn:microsoft:playready', 'pro'); if (!proNode || !shaka.util.TXml.getTextContents(proNode)) { return null; } const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils; const textContent = /** @type {string} */ (shaka.util.TXml.getTextContents(proNode)); const data = Uint8ArrayUtils.fromBase64(textContent); const systemId = new Uint8Array([ 0x9a, 0x04, 0xf0, 0x79, 0x98, 0x40, 0x42, 0x86, 0xab, 0x92, 0xe6, 0x5b, 0xe0, 0x88, 0x5f, 0x95, ]); const keyIds = new Set(); const psshVersion = 0; const pssh = shaka.util.Pssh.createPssh(data, systemId, keyIds, psshVersion); return [ { initData: pssh, initDataType: 'cenc', keyId: element.keyId, }, ]; } /** * Creates ClearKey initData from Default_KID value retrieved from previously * parsed ContentProtection tag. * @param {shaka.dash.ContentProtection.Element} element * @param {!Set<string>} keyIds * @return {?Array<shaka.extern.InitDataOverride>} * @private */ static getInitDataClearKey_(element, keyIds) { if (keyIds.size == 0) { return null; } const systemId = new Uint8Array([ 0x10, 0x77, 0xef, 0xec, 0xc0, 0xb2, 0x4d, 0x02, 0xac, 0xe3, 0x3c, 0x1e, 0x52, 0xe2, 0xfb, 0x4b, ]); const data = new Uint8Array([]); const psshVersion = 1; const pssh = shaka.util.Pssh.createPssh(data, systemId, keyIds, psshVersion); return [ { initData: pssh, initDataType: 'cenc', keyId: element.keyId, }, ]; } /** * Creates DrmInfo objects from the given element. * * @param {Array<shaka.extern.InitDataOverride>} defaultInit * @param {string} encryptionScheme * @param {!Array<shaka.dash.ContentProtection.Element>} elements * @param {!Object<string, string>} keySystemsByURI * @param {!Set<string>} keyIds * @return {!Array<shaka.extern.DrmInfo>} * @private */ static convertElements_(defaultInit, encryptionScheme, elements, keySystemsByURI, keyIds) { const ContentProtection = shaka.dash.ContentProtection; const ManifestParserUtils = shaka.util.ManifestParserUtils; const licenseUrlParsers = ContentProtection.licenseUrlParsers_; /** @type {!Array<shaka.extern.DrmInfo>} */ const out = []; for (const element of elements) { const keySystem = keySystemsByURI[element.schemeUri]; if (keySystem) { goog.asserts.assert( !element.init || element.init.length, 'Init data must be null or non-empty.'); const proInitData = ContentProtection.getInitDataFromPro_(element); let clearKeyInitData = null; if (element.schemeUri === shaka.dash.ContentProtection.ClearKeySchemeUri_) { clearKeyInitData = ContentProtection.getInitDataClearKey_(element, keyIds); } const initData = element.init || defaultInit || proInitData || clearKeyInitData; const info = ManifestParserUtils.createDrmInfo( keySystem, encryptionScheme, initData); const licenseParser = licenseUrlParsers.get(keySystem); if (licenseParser) { info.licenseServerUri = licenseParser(element); } out.push(info); } } return out; } /** * Parses the given ContentProtection elements. If there is an error, it * removes those elements. * * @param {!Array<!shaka.extern.xml.Node>} elements * @return {!Array<shaka.dash.ContentProtection.Element>} * @private */ static parseElements_(elements) { /** @type {!Array<shaka.dash.ContentProtection.Element>} */ const out = []; for (const element of elements) { const parsed = shaka.dash.ContentProtection.parseElement_(element); if (parsed) { out.push(parsed); } } return out; } /** * Parses the given ContentProtection element. * * @param {!shaka.extern.xml.Node} elem * @return {?shaka.dash.ContentProtection.Element} * @private */ static parseElement_(elem) { const NS = shaka.dash.ContentProtection.CencNamespaceUri_; const TXml = shaka.util.TXml; /** @type {?string} */ let schemeUri = elem.attributes['schemeIdUri']; /** @type {?string} */ let keyId = TXml.getAttributeNS(elem, NS, 'default_KID'); /** @type {!Array<string>} */ const psshs = TXml.findChildrenNS(elem, NS, 'pssh') .map(TXml.getContents); const encryptionScheme = elem.attributes['value']; 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.includes(' ')) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_MULTIPLE_KEY_IDS_NOT_SUPPORTED); } } /** @type {!Array<shaka.extern.InitDataOverride>} */ let init = []; try { // Try parsing PSSH data. init = psshs.map((pssh) => { return { initDataType: 'cenc', initData: shaka.util.Uint8ArrayUtils.fromBase64(pssh), keyId: null, }; }); } catch (e) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_PSSH_BAD_ENCODING); } return { node: elem, schemeUri, keyId, init: (init.length > 0 ? init : null), encryptionScheme, }; } /** * Parses the given AES-128 ContentProtection element. * * @param {shaka.dash.ContentProtection.Element} element * @return {?shaka.dash.ContentProtection.Aes128Info} * @private */ static parseAes128_(element) { // Check if the Web Crypto API is available. if (!window.crypto || !window.crypto.subtle) { shaka.log.alwaysWarn('Web Crypto API is not available to decrypt ' + 'AES-128. (Web Crypto only exists in secure origins like https)'); throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.NO_WEB_CRYPTO_API); } const namespace = 'urn:mpeg:dash:schema:sea:2012'; const segmentEncryption = shaka.util.TXml.findChildNS( element.node, namespace, 'SegmentEncryption'); if (!segmentEncryption) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_UNSUPPORTED_AES_128); } const aesSchemeIdUri = 'urn:mpeg:dash:sea:aes128-cbc:2013'; const segmentEncryptionSchemeIdUri = segmentEncryption.attributes['schemeIdUri']; if (segmentEncryptionSchemeIdUri != aesSchemeIdUri) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_UNSUPPORTED_AES_128); } const cryptoPeriod = shaka.util.TXml.findChildNS( element.node, namespace, 'CryptoPeriod'); if (!cryptoPeriod) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_UNSUPPORTED_AES_128); } const ivHex = cryptoPeriod.attributes['IV']; const keyUri = shaka.util.StringUtils.htmlUnescape( cryptoPeriod.attributes['keyUriTemplate']); if (!ivHex || !keyUri) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.DASH_UNSUPPORTED_AES_128); } // Exclude 0x at the start of string. const iv = shaka.util.Uint8ArrayUtils.fromHex(ivHex.substr(2)); if (iv.byteLength != 16) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.AES_128_INVALID_IV_LENGTH); } return { keyUri, iv, }; } }; /** * @typedef {{ * defaultKeyId: ?string, * defaultInit: Array<shaka.extern.InitDataOverride>, * drmInfos: !Array<shaka.extern.DrmInfo>, * aes128Info: ?shaka.dash.ContentProtection.Aes128Info, * 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<shaka.extern.InitDataOverride>} defaultInit * The default init data override. This can be null to indicate that there * is no default. * @property {!Array<shaka.extern.DrmInfo>} drmInfos * The DrmInfo objects. * @property {?shaka.dash.ContentProtection.Aes128Info} aes128Info * The AES-128 key info. * @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 {{ * keyUri: string, * iv: !Uint8Array * }} * * @description * Contains information about the AES-128 keyUri and IV found at the * AdaptationSet level. * * @property {string} method * The keyUri in the manifest. * @property {!Uint8Array} iv * The IV in the manifest. */ shaka.dash.ContentProtection.Aes128Info; /** * @typedef {{ * node: !shaka.extern.xml.Node, * schemeUri: string, * keyId: ?string, * init: Array<shaka.extern.InitDataOverride>, * encryptionScheme: ?string * }} * * @description * The parsed result of a single ContentProtection element. * * @property {!shaka.extern.xml.Node} node * The ContentProtection XML element. * @property {string} schemeUri * The scheme URI. * @property {?string} keyId * The default key ID, if present. * @property {Array<shaka.extern.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. * @property {?string} encryptionScheme * The encryption scheme, if present. */ shaka.dash.ContentProtection.Element; /** * A map of key system name to license server url parser. * * @const {!Map<string, function(shaka.dash.ContentProtection.Element)>} * @private */ shaka.dash.ContentProtection.licenseUrlParsers_ = new Map() .set('com.apple.fps', shaka.dash.ContentProtection.getFairPlayLicenseUrl) .set('com.widevine.alpha', shaka.dash.ContentProtection.getWidevineLicenseUrl) .set('com.microsoft.playready', shaka.dash.ContentProtection.getPlayReadyLicenseUrl) .set('com.microsoft.playready.recommendation', shaka.dash.ContentProtection.getPlayReadyLicenseUrl) .set('com.microsoft.playready.software', shaka.dash.ContentProtection.getPlayReadyLicenseUrl) .set('com.microsoft.playready.hardware', shaka.dash.ContentProtection.getPlayReadyLicenseUrl) .set('org.w3.clearkey', shaka.dash.ContentProtection.getClearKeyLicenseUrl); /** * @const {string} * @private */ shaka.dash.ContentProtection.MP4Protection_ = 'urn:mpeg:dash:mp4protection:2011'; /** * @const {string} * @private */ shaka.dash.ContentProtection.Aes128Protection_ = 'urn:mpeg:dash:sea:2012'; /** * @const {string} * @private */ shaka.dash.ContentProtection.CencNamespaceUri_ = 'urn:mpeg:cenc:2013'; /** * @const {string} * @private */ shaka.dash.ContentProtection.ClearKeyNamespaceUri_ = 'http://dashif.org/guidelines/clearKey'; /** * @const {string} * @private */ shaka.dash.ContentProtection.ClearKeySchemeUri_ = 'urn:uuid:e2719d58-a985-b3c9-781a-b030af78d30e'; /** * @const {string} * @private */ shaka.dash.ContentProtection.DashIfNamespaceUri_ = 'https://dashif.org/CPS';