diff --git a/demo/common/assets.js b/demo/common/assets.js index bccca179d6b..f1d9e580374 100644 --- a/demo/common/assets.js +++ b/demo/common/assets.js @@ -346,6 +346,14 @@ shakaAssets.testAssets = [ .addFeature(shakaAssets.Feature.SURROUND) .addFeature(shakaAssets.Feature.OFFLINE) .addLicenseServer('com.widevine.alpha', 'https://cwip-shaka-proxy.appspot.com/no_auth'), + new ShakaDemoAssetInfo( + /* name= */ 'Angel One (HLS, TS, AES-128 key rotation, Video Only)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/angel_one.png', + /* manifestUri= */ 'https://storage.googleapis.com/shaka-demo-assets/angel-one-aes-key-rotation/master.m3u8', + /* source= */ shakaAssets.Source.SHAKA) + .addFeature(shakaAssets.Feature.HLS) + .addFeature(shakaAssets.Feature.MP2TS) + .addFeature(shakaAssets.Feature.OFFLINE), new ShakaDemoAssetInfo( /* name= */ 'Sintel 4k (multicodec)', /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/sintel.png', @@ -904,6 +912,15 @@ shakaAssets.testAssets = [ .addFeature(shakaAssets.Feature.HLS) .addFeature(shakaAssets.Feature.MP2TS) .addFeature(shakaAssets.Feature.OFFLINE), + new ShakaDemoAssetInfo( + /* name= */ 'Art of Motion (HLS, TS, AES-128)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/art_of_motion.png', + /* manifestUri= */ 'https://bitmovin-a.akamaihd.net/content/art-of-motion_drm/m3u8s/11331.m3u8', + /* source= */ shakaAssets.Source.BITCODIN) + .addFeature(shakaAssets.Feature.HIGH_DEFINITION) + .addFeature(shakaAssets.Feature.HLS) + .addFeature(shakaAssets.Feature.MP2TS) + .addFeature(shakaAssets.Feature.OFFLINE), new ShakaDemoAssetInfo( /* name= */ 'Sintel (HLS, TS, 4k)', /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/sintel.png', diff --git a/externs/shaka/manifest.js b/externs/shaka/manifest.js index 4bf22239b61..c24a20f90bd 100644 --- a/externs/shaka/manifest.js +++ b/externs/shaka/manifest.js @@ -244,6 +244,33 @@ shaka.extern.Variant; shaka.extern.CreateSegmentIndexFunction; +/** + * @typedef {{ + * method: string, + * cryptoKey: !webCrypto.CryptoKey, + * iv: (!Uint8Array|undefined), + * firstMediaSequenceNumber: number + * }} + * + * @description + * AES-128 key and iv info from the HLS manifest. + * + * @property {string} method + * The key method defined in the HLS manifest. + * @property {!webCrypto.CryptoKey} cryptoKey + * Web crypto key object of the AES-128 CBC key. + * @property {(!Uint8Array|undefined)} iv + * The IV in the HLS manifest, if defined. See HLS RFC 8216 Section 5.2 for + * handling undefined IV. + * @property {number} firstMediaSequenceNumber + * The starting Media Sequence Number of the playlist, used when IV is + * undefined. + * + * @exportDoc + */ +shaka.extern.HlsAes128Key; + + /** * @typedef {{ * id: number, diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index 787eaeb2f0a..4c869f9f96d 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -37,6 +37,7 @@ goog.require('shaka.util.OperationManager'); goog.require('shaka.util.Pssh'); goog.require('shaka.util.Timer'); goog.require('shaka.util.Platform'); +goog.require('shaka.util.Uint8ArrayUtils'); goog.require('shaka.util.XmlUtils'); goog.requireType('shaka.hls.Segment'); @@ -326,7 +327,7 @@ shaka.hls.HlsParser = class { const stream = streamInfo.stream; - const segments = this.createSegments_( + const segments = await this.createSegments_( streamInfo.verbatimMediaPlaylistUri, playlist, stream.type, stream.mimeType, streamInfo.mediaSequenceToStartTime, mediaVariables, stream.codecs); @@ -1644,34 +1645,30 @@ shaka.hls.HlsParser = class { if (method != 'NONE') { encrypted = true; - // We do not support AES-128 encryption with HLS yet. So, do not create - // StreamInfo for the playlist encrypted with AES-128. - // TODO: Remove the error message once we add support for AES-128. if (method == 'AES-128') { - shaka.log.warning('Unsupported HLS Encryption', method); + // These keys are handled separately. this.aesEncrypted_ = true; - return null; - } - - const keyFormat = drmTag.getRequiredAttrValue('KEYFORMAT'); - const drmParser = - shaka.hls.HlsParser.KEYFORMATS_TO_DRM_PARSERS_[keyFormat]; - - const drmInfo = drmParser ? drmParser(drmTag, mimeType) : null; - if (drmInfo) { - if (drmInfo.keyIds) { - for (const keyId of drmInfo.keyIds) { - keyIds.add(keyId); + } else { + const keyFormat = drmTag.getRequiredAttrValue('KEYFORMAT'); + const drmParser = + shaka.hls.HlsParser.KEYFORMATS_TO_DRM_PARSERS_[keyFormat]; + + const drmInfo = drmParser ? drmParser(drmTag, mimeType) : null; + if (drmInfo) { + if (drmInfo.keyIds) { + for (const keyId of drmInfo.keyIds) { + keyIds.add(keyId); + } } + drmInfos.push(drmInfo); + } else { + shaka.log.warning('Unsupported HLS KEYFORMAT', keyFormat); } - drmInfos.push(drmInfo); - } else { - shaka.log.warning('Unsupported HLS KEYFORMAT', keyFormat); } } } - if (encrypted && !drmInfos.length) { + if (encrypted && !drmInfos.length && !this.aesEncrypted_) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, @@ -1688,7 +1685,7 @@ shaka.hls.HlsParser = class { let segments; try { - segments = this.createSegments_(verbatimMediaPlaylistUri, + segments = await this.createSegments_(verbatimMediaPlaylistUri, playlist, type, mimeType, mediaSequenceToStartTime, mediaVariables, codecs); } catch (error) { @@ -1765,6 +1762,78 @@ shaka.hls.HlsParser = class { } + /** + * @param {!shaka.hls.Tag} drmTag + * @param {!shaka.hls.Playlist} playlist + * @return {!Promise.} + * @private + */ + async parseAES128DrmTag_(drmTag, playlist) { + // 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); + } + + // HLS RFC 8216 Section 5.2: + // An EXT-X-KEY tag with a KEYFORMAT of "identity" that does not have an IV + // attribute indicates that the Media Sequence Number is to be used as the + // IV when decrypting a Media Segment, by putting its big-endian binary + // representation into a 16-octet (128-bit) buffer and padding (on the left) + // with zeros. + let firstMediaSequenceNumber = 0; + let iv; + const ivHex = drmTag.getAttributeValue('IV', ''); + if (!ivHex) { + // Media Sequence Number will be used as IV. + firstMediaSequenceNumber = shaka.hls.Utils.getFirstTagWithNameAsNumber( + playlist.tags, 'EXT-X-MEDIA-SEQUENCE', 0); + } else { + // Exclude 0x at the start of string. + 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.HLS_AES_128_INVALID_KEY_OR_IV_LENGTH, + 'IV'); + } + } + + const keyUri = shaka.hls.Utils.constructAbsoluteUri( + playlist.absoluteUri, drmTag.getRequiredAttrValue('URI')); + + const requestType = shaka.net.NetworkingEngine.RequestType.KEY; + const request = shaka.net.NetworkingEngine.makeRequest( + [keyUri], this.config_.retryParameters); + const keyResponse = await this.makeNetworkRequest_(request, requestType); + + // keyResponse.status is undefined when URI is "data:text/plain;base64," + if (!keyResponse.data || keyResponse.data.byteLength != 16) { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.HLS_AES_128_INVALID_KEY_OR_IV_LENGTH, + 'KEY'); + } + + const cryptoKey = await window.crypto.subtle.importKey( + 'raw', keyResponse.data, 'AES-CBC', true, ['decrypt']); + + // TODO: TO DO: + // 4. make tests, including tests for multi-key and such + // 6. disable isReadableStreamSupported when AES-128 is present? maybe + // 8. once this is accepted, make this into a rebase on top of their PR + // Joey suggests that an interactive rebase could be easier (rebase -i) + // TODO: make an AES-128 in mp4 asset, and put it in pantheon + return {method: 'AES-128', cryptoKey, iv, firstMediaSequenceNumber}; + } + + /** * @param {!shaka.hls.Playlist} playlist * @private @@ -1939,12 +2008,13 @@ shaka.hls.HlsParser = class { * @param {!Map.} variables * @param {string} absoluteMediaPlaylistUri * @param {string} type + * @param {shaka.extern.HlsAes128Key=} hlsAes128Key * @return {shaka.media.SegmentReference} * @private */ createSegmentReference_( initSegmentReference, previousReference, hlsSegment, startTime, - variables, absoluteMediaPlaylistUri, type) { + variables, absoluteMediaPlaylistUri, type, hlsAes128Key) { const tags = hlsSegment.tags; const absoluteSegmentUri = this.variableSubstitution_( hlsSegment.absoluteUri, variables); @@ -2039,6 +2109,7 @@ shaka.hls.HlsParser = class { partialStatus = shaka.media.SegmentReference.Status.MISSING; } + // TODO: can there can be partial AES segments? const partial = new shaka.media.SegmentReference( pStartTime, pEndTime, @@ -2120,6 +2191,7 @@ shaka.hls.HlsParser = class { tileDuration, syncTime, status, + hlsAes128Key, ); } @@ -2174,10 +2246,10 @@ shaka.hls.HlsParser = class { * @param {!Map.} mediaSequenceToStartTime * @param {!Map.} variables * @param {string} codecs - * @return {!Array.} + * @return {!Promise.>} * @private */ - createSegments_(verbatimMediaPlaylistUri, playlist, type, mimeType, + async createSegments_(verbatimMediaPlaylistUri, playlist, type, mimeType, mediaSequenceToStartTime, variables, codecs) { /** @type {Array.} */ const hlsSegments = playlist.segments; @@ -2186,6 +2258,9 @@ shaka.hls.HlsParser = class { /** @type {shaka.media.InitSegmentReference} */ let initSegmentRef; + /** @type {shaka.extern.HlsAes128Key|undefined} */ + let hlsAes128Key = undefined; + // We may need to look at the media itself to determine a segment start // time. const mediaSequenceNumber = shaka.hls.Utils.getFirstTagWithNameAsNumber( @@ -2214,6 +2289,15 @@ shaka.hls.HlsParser = class { (i == 0) ? firstStartTime : previousReference.endTime; position = mediaSequenceNumber + skippedSegments + i; + // Apply new AES-128 tags as you see them, keeping a running total. + for (const drmTag of item.tags) { + if (drmTag.name == 'EXT-X-KEY' && + drmTag.getRequiredAttrValue('METHOD') == 'AES-128') { + // eslint-disable-next-line no-await-in-loop + hlsAes128Key = await this.parseAES128DrmTag_(drmTag, playlist); + } + } + mediaSequenceToStartTime.set(position, startTime); initSegmentRef = this.getInitSegmentReference_(playlist.absoluteUri, @@ -2238,7 +2322,8 @@ shaka.hls.HlsParser = class { startTime, variables, playlist.absoluteUri, - type); + type, + hlsAes128Key); previousReference = reference; if (reference) { diff --git a/lib/media/segment_index.js b/lib/media/segment_index.js index b9fe48fb1e0..ee51b633747 100644 --- a/lib/media/segment_index.js +++ b/lib/media/segment_index.js @@ -529,6 +529,14 @@ shaka.media.SegmentIterator = class { this.currentPartialPosition_ = partialSegmentIndex; } + /** + * @return {number} + * @export + */ + currentPosition() { + return this.currentPosition_; + } + /** * @return {shaka.media.SegmentReference} * @export diff --git a/lib/media/segment_reference.js b/lib/media/segment_reference.js index f504ab5237c..2bfe2c3fb9a 100644 --- a/lib/media/segment_reference.js +++ b/lib/media/segment_reference.js @@ -41,6 +41,8 @@ shaka.media.InitSegmentReference = class { /** @const {shaka.extern.MediaQualityInfo|null} */ this.mediaQuality = mediaQuality; + + // TODO: does this need AES info too? } /** @@ -166,12 +168,15 @@ shaka.media.SegmentReference = class { * @param {shaka.media.SegmentReference.Status=} status * The segment status is used to indicate that a segment does not exist or is * not available. + * @param {shaka.extern.HlsAes128Key=} hlsAes128Key + * The segment's AES-128-CBC full segment encryption key and iv. */ constructor( startTime, endTime, uris, startByte, endByte, initSegmentReference, timestampOffset, appendWindowStart, appendWindowEnd, partialReferences = [], tilesLayout = '', tileDuration = null, - syncTime = null, status = shaka.media.SegmentReference.Status.AVAILABLE) { + syncTime = null, status = shaka.media.SegmentReference.Status.AVAILABLE, + hlsAes128Key) { // A preload hinted Partial Segment has the same startTime and endTime. goog.asserts.assert(startTime <= endTime, 'startTime must be less than or equal to endTime'); @@ -233,6 +238,9 @@ shaka.media.SegmentReference = class { /** @type {shaka.media.SegmentReference.Status} */ this.status = status; + + /** @type {shaka.extern.HlsAes128Key|undefined} */ + this.hlsAes128Key = hlsAes128Key; } /** diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js index f491acb389e..ec7c404e39e 100644 --- a/lib/media/streaming_engine.js +++ b/lib/media/streaming_engine.js @@ -1273,11 +1273,15 @@ shaka.media.StreamingEngine = class { 'ReadableStream is not supported by the browser.'); } const fetchSegment = this.fetch_(mediaState, reference); - const result = await fetchSegment; + let result = await fetchSegment; this.destroyer_.ensureNotDestroyed(); if (this.fatalError_) { return; } + if (reference.hlsAes128Key && iter) { + result = await this.aes128Decrypt_(result, reference, iter); + } + this.destroyer_.ensureNotDestroyed(); // If the text stream gets switched between fetch_() and append_(), the // new text parser is initialized, but the new init segment is not @@ -1368,6 +1372,28 @@ shaka.media.StreamingEngine = class { } } + /** + * @param {!BufferSource} rawResult + * @param {!shaka.media.SegmentReference} reference + * @param {!shaka.media.SegmentIterator} iter + * @return {!Promise.} finalResult + * @private + */ + aes128Decrypt_(rawResult, reference, iter) { + let iv = reference.hlsAes128Key.iv; + if (!iv) { + iv = shaka.util.BufferUtils.toUint8(new ArrayBuffer(16)); + let sequence = reference.hlsAes128Key.firstMediaSequenceNumber + + iter.currentPosition(); + for (let i = iv.byteLength - 1; i >= 0; i--) { + iv[i] = sequence & 0xff; + sequence >>= 8; + } + } + return window.crypto.subtle.decrypt( + {name: 'AES-CBC', iv}, reference.hlsAes128Key.cryptoKey, rawResult); + } + /** * Clear per-stream error states and retry any failed streams. diff --git a/lib/net/networking_engine.js b/lib/net/networking_engine.js index 17bf470acc8..cefa6c8a26a 100644 --- a/lib/net/networking_engine.js +++ b/lib/net/networking_engine.js @@ -749,6 +749,7 @@ shaka.net.NetworkingEngine.RequestType = { 'APP': 3, 'TIMING': 4, 'SERVER_CERTIFICATE': 5, + 'KEY': 6, }; diff --git a/lib/util/error.js b/lib/util/error.js index 529d740cf88..0a9d8ccf4f8 100644 --- a/lib/util/error.js +++ b/lib/util/error.js @@ -699,6 +699,17 @@ shaka.util.Error.Code = { */ 'HLS_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED': 4041, + /** + * Web Crypto API is not available (to decrypt AES-128 streams). Web Crypto + * only exists in secure origins like https. + */ + 'NO_WEB_CRYPTO_API': 4042, + + /** + * AES-128 encryption key or iv length should be 16 bytes. + */ + 'HLS_AES_128_INVALID_KEY_OR_IV_LENGTH': 4043, + // RETIRED: 'INCONSISTENT_BUFFER_STATE': 5000, // RETIRED: 'INVALID_SEGMENT_INDEX': 5001,