From 6194021a3d4ea5dae22ade6713bb077875a4ee9d Mon Sep 17 00:00:00 2001 From: theodab Date: Fri, 12 Aug 2022 10:50:32 -0700 Subject: [PATCH] feat(hls): Support AES-128 in HLS (#4386) Expands on the original PR (#3880) by adding support for MP4 and key rotation. Close #850 Co-authored-by: wjywbs --- demo/common/assets.js | 25 +++++ externs/shaka/manifest.js | 43 ++++++++ lib/hls/hls_parser.js | 134 +++++++++++++++++++----- lib/media/segment_index.js | 8 ++ lib/media/segment_reference.js | 8 +- lib/media/streaming_engine.js | 40 ++++++- lib/net/networking_engine.js | 1 + lib/util/error.js | 16 +++ test/hls/hls_parser_unit.js | 82 +++++++++------ test/media/streaming_engine_unit.js | 84 ++++++++++++++- test/test/util/simple_fakes.js | 4 + test/test/util/streaming_engine_util.js | 12 ++- 12 files changed, 389 insertions(+), 68 deletions(-) diff --git a/demo/common/assets.js b/demo/common/assets.js index bccca179d6..a1d97e1671 100644 --- a/demo/common/assets.js +++ b/demo/common/assets.js @@ -346,6 +346,22 @@ 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= */ 'Sintel (HLS, TS, AES-128 key rotation)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/sintel.png', + /* manifestUri= */ 'https://storage.googleapis.com/shaka-demo-assets/sintel-ts-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 (HLS, FMP4, AES-128)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/sintel.png', + /* manifestUri= */ 'https://storage.googleapis.com/shaka-demo-assets/sintel-fmp4-aes/master.m3u8', + /* source= */ shakaAssets.Source.SHAKA) + .addFeature(shakaAssets.Feature.HLS) + .addFeature(shakaAssets.Feature.MP4) + .addFeature(shakaAssets.Feature.OFFLINE), new ShakaDemoAssetInfo( /* name= */ 'Sintel 4k (multicodec)', /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/sintel.png', @@ -904,6 +920,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 4bf22239b6..de5b124093 100644 --- a/externs/shaka/manifest.js +++ b/externs/shaka/manifest.js @@ -244,6 +244,49 @@ shaka.extern.Variant; shaka.extern.CreateSegmentIndexFunction; +/** + * @typedef {{ + * method: string, + * cryptoKey: (webCrypto.CryptoKey|undefined), + * fetchKey: (shaka.extern.CreateSegmentIndexFunction|undefined), + * 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|undefined} cryptoKey + * Web crypto key object of the AES-128 CBC key. If unset, the "fetchKey" + * property should be provided. + * @property {shaka.extern.FetchCryptoKeysFunction|undefined} fetchKey + * A function that fetches the key. + * Should be provided if the "cryptoKey" property is unset. + * Should update this object in-place, to set "cryptoKey". + * @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; + + +/** + * A function that fetches the crypto keys for AES-128. + * Returns a promise that resolves when the keys have been fetched. + * + * @typedef {function(): !Promise} + * @exportDoc + */ +shaka.extern.FetchCryptoKeysFunction; + + /** * @typedef {{ * id: number, diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index 787eaeb2f0..4a7bf1836f 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'); @@ -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,9 +1685,8 @@ shaka.hls.HlsParser = class { let segments; try { - segments = this.createSegments_(verbatimMediaPlaylistUri, - playlist, type, mimeType, mediaSequenceToStartTime, mediaVariables, - codecs); + segments = this.createSegments_(verbatimMediaPlaylistUri, playlist, type, + mimeType, mediaSequenceToStartTime, mediaVariables, codecs); } catch (error) { if (error.code == shaka.util.Error.Code.HLS_INTERNAL_SKIP_STREAM) { shaka.log.alwaysWarn('Skipping unsupported HLS stream', @@ -1765,6 +1761,78 @@ shaka.hls.HlsParser = class { } + /** + * @param {!shaka.hls.Tag} drmTag + * @param {!shaka.hls.Playlist} playlist + * @return {!shaka.extern.HlsAes128Key} + * @private + */ + 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_IV_LENGTH); + } + } + + 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 keyInfo = {method: 'AES-128', iv, firstMediaSequenceNumber}; + + // Don't download the key object until the segment is parsed, to avoid a + // startup delay for long manifests with lots of keys. + keyInfo.fetchKey = async () => { + 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_LENGTH); + } + + keyInfo.cryptoKey = await window.crypto.subtle.importKey( + 'raw', keyResponse.data, 'AES-CBC', true, ['decrypt']); + keyInfo.fetchKey = undefined; // No longer needed. + }; + + return keyInfo; + } + + /** * @param {!shaka.hls.Playlist} playlist * @private @@ -1939,12 +2007,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 +2108,8 @@ shaka.hls.HlsParser = class { partialStatus = shaka.media.SegmentReference.Status.MISSING; } + // We do not set the AES-128 key information for partial segments, as we + // do not support AES-128 and low-latency at the same time. const partial = new shaka.media.SegmentReference( pStartTime, pEndTime, @@ -2120,6 +2191,7 @@ shaka.hls.HlsParser = class { tileDuration, syncTime, status, + hlsAes128Key, ); } @@ -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,14 @@ 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') { + hlsAes128Key = this.parseAES128DrmTag_(drmTag, playlist); + } + } + mediaSequenceToStartTime.set(position, startTime); initSegmentRef = this.getInitSegmentReference_(playlist.absoluteUri, @@ -2238,7 +2321,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 b9fe48fb1e..ee51b63374 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 f504ab5237..4fcaba4646 100644 --- a/lib/media/segment_reference.js +++ b/lib/media/segment_reference.js @@ -166,12 +166,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 = null) { // 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 +236,9 @@ shaka.media.SegmentReference = class { /** @type {shaka.media.SegmentReference.Status} */ this.status = status; + + /** @type {?shaka.extern.HlsAes128Key} */ + this.hlsAes128Key = hlsAes128Key; } /** diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js index a567099246..c3b570f13f 100644 --- a/lib/media/streaming_engine.js +++ b/lib/media/streaming_engine.js @@ -1233,7 +1233,10 @@ shaka.media.StreamingEngine = class { stream.mimeType == 'audio/mp4'; const isReadableStreamSupported = window.ReadableStream; // Enable MP4 low latency streaming with ReadableStream chunked data. - if (this.config_.lowLatencyMode && isReadableStreamSupported && isMP4) { + // Disabled when AES-128 is present, as we cannot decrypt part of a + // segment. + if (this.config_.lowLatencyMode && isReadableStreamSupported && isMP4 && + !reference.hlsAes128Key) { let remaining = new Uint8Array(0); const streamDataCallback = async (data) => { this.destroyer_.ensureNotDestroyed(); @@ -1273,11 +1276,16 @@ 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) { + goog.asserts.assert(iter, 'mediaState.segmentIterator should exist'); + 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 +1376,34 @@ shaka.media.StreamingEngine = class { } } + /** + * @param {!BufferSource} rawResult + * @param {!shaka.media.SegmentReference} reference + * @param {!shaka.media.SegmentIterator} iter + * @return {!Promise.} finalResult + * @private + */ + async aes128Decrypt_(rawResult, reference, iter) { + const key = reference.hlsAes128Key; + if (!key.cryptoKey) { + goog.asserts.assert(key.fetchKey, 'If AES-128 cryptoKey was not ' + + 'preloaded, fetchKey function should be provided'); + await key.fetchKey(); + goog.asserts.assert(key.cryptoKey, 'AES-128 cryptoKey should now be set'); + } + let iv = key.iv; + if (!iv) { + iv = shaka.util.BufferUtils.toUint8(new ArrayBuffer(16)); + let sequence = key.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}, key.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 17bf470acc..cefa6c8a26 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 529d740cf8..236833a403 100644 --- a/lib/util/error.js +++ b/lib/util/error.js @@ -699,6 +699,22 @@ 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 iv length should be 16 bytes. + */ + 'HLS_AES_128_INVALID_IV_LENGTH': 4043, + + /** + * AES-128 encryption key length should be 16 bytes. + */ + 'HLS_AES_128_INVALID_KEY_LENGTH': 4044, + // RETIRED: 'INCONSISTENT_BUFFER_STATE': 5000, // RETIRED: 'INVALID_SEGMENT_INDEX': 5001, diff --git a/test/hls/hls_parser_unit.js b/test/hls/hls_parser_unit.js index 2c613823f2..dc3f5fc4cb 100644 --- a/test/hls/hls_parser_unit.js +++ b/test/hls/hls_parser_unit.js @@ -33,6 +33,8 @@ describe('HlsParser', () => { let segmentData; /** @type {!Uint8Array} */ let selfInitializingSegmentData; + /** @type {!Uint8Array} */ + let aes128Key; afterEach(() => { shaka.log.alwaysWarn = originalAlwaysWarn; @@ -75,6 +77,11 @@ describe('HlsParser', () => { selfInitializingSegmentData = shaka.util.Uint8ArrayUtils.concat(initSegmentData, segmentData); + aes128Key = new Uint8Array([ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + ]); + fakeNetEngine = new shaka.test.FakeNetworkingEngine(); config = shaka.util.PlayerConfiguration.createDefault().manifest; @@ -2432,7 +2439,7 @@ describe('HlsParser', () => { expect(initSegments[1].getUris()[0]).toBe('test:/init2.mp4'); }); - it('drops variants encrypted with AES-128', async () => { + it('parses variants encrypted with AES-128', async () => { const master = [ '#EXTM3U\n', '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1,mp4a",', @@ -2441,10 +2448,15 @@ describe('HlsParser', () => { '#EXT-X-STREAM-INF:BANDWIDTH=300,CODECS="avc1,mp4a",', 'RESOLUTION=960x540,FRAME-RATE=120,AUDIO="aud2"\n', 'video2\n', + '#EXT-X-STREAM-INF:BANDWIDTH=300,CODECS="avc1,mp4a",', + 'RESOLUTION=960x540,FRAME-RATE=120,AUDIO="aud3"\n', + 'video3\n', '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud1",LANGUAGE="eng",', 'URI="audio"\n', '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud2",LANGUAGE="fr",', 'URI="audio2"\n', + '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud3",LANGUAGE="de",', + 'URI="audio3"\n', ].join(''); const media = [ @@ -2456,17 +2468,26 @@ describe('HlsParser', () => { 'main.mp4', ].join(''); - const mediaWithAesEncryption = [ + const mediaWithMp4AesEncryption = [ '#EXTM3U\n', '#EXT-X-PLAYLIST-TYPE:VOD\n', '#EXT-X-KEY:METHOD=AES-128,', - 'URI="800k.key\n', + 'URI="800k.key"\n', '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', '#EXTINF:5,\n', '#EXT-X-BYTERANGE:121090@616\n', 'main.mp4', ].join(''); + const mediaWithTSAesEncryption = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-KEY:METHOD=AES-128,', + 'URI="800k.key"\n', + '#EXTINF:5,\n', + 'main.ts', + ].join(''); + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { manifest.anyTimeline(); manifest.addPartialVariant((variant) => { @@ -2478,6 +2499,24 @@ describe('HlsParser', () => { stream.language = 'en'; }); }); + manifest.addPartialVariant((variant) => { + variant.bandwidth = 300; + variant.addPartialStream(ContentType.VIDEO, (stream) => { + stream.size(960, 540); + }); + variant.addPartialStream(ContentType.AUDIO, (stream) => { + stream.language = 'fr'; + }); + }); + manifest.addPartialVariant((variant) => { + variant.bandwidth = 300; + variant.addPartialStream(ContentType.VIDEO, (stream) => { + stream.size(960, 540); + }); + variant.addPartialStream(ContentType.AUDIO, (stream) => { + stream.language = 'de'; + }); + }); manifest.sequenceMode = true; }); @@ -2485,12 +2524,15 @@ describe('HlsParser', () => { .setResponseText('test:/master', master) .setResponseText('test:/audio', media) .setResponseText('test:/audio2', media) + .setResponseText('test:/audio3', media) .setResponseText('test:/video', media) - .setResponseText('test:/video2', mediaWithAesEncryption) + .setResponseText('test:/video2', mediaWithMp4AesEncryption) + .setResponseText('test:/video3', mediaWithTSAesEncryption) .setResponseText('test:/main.vtt', vttText) .setResponseValue('test:/init.mp4', initSegmentData) .setResponseValue('test:/main.mp4', segmentData) .setResponseValue('test:/main.test', segmentData) + .setResponseValue('test:/800k.key', aes128Key) .setResponseValue('test:/selfInit.mp4', selfInitializingSegmentData); const actual = await parser.start('test:/master', playerInterface); @@ -2674,7 +2716,9 @@ describe('HlsParser', () => { .setResponseText('test:/video', media) .setResponseValue('test:/main.exe', segmentData) .setResponseValue('test:/init.mp4', initSegmentData) - .setResponseValue('test:/main.mp4', segmentData); + .setResponseValue('test:/main.mp4', segmentData) + .setResponseValue('data:text/plain;base64,AAECAwQFBgcICQoLDA0ODw==', + aes128Key); await expectAsync(parser.start('test:/master', playerInterface)) .toBeRejectedWith(Util.jasmineError(error)); @@ -2708,34 +2752,6 @@ describe('HlsParser', () => { await verifyError(master, media, error); }); - it('if all variants are encrypted with AES-128', async () => { - const master = [ - '#EXTM3U\n', - '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1",', - 'RESOLUTION=960x540,FRAME-RATE=60\n', - 'video\n', - ].join(''); - - const media = [ - '#EXTM3U\n', - '#EXT-X-TARGETDURATION:6\n', - '#EXT-X-PLAYLIST-TYPE:VOD\n', - '#EXT-X-KEY:METHOD=AES-128,', - 'URI="data:text/plain;base64\n', - '#EXT-X-MAP:URI="init.mp4"\n', - '#EXTINF:5,\n', - '#EXT-X-BYTERANGE:121090@616\n', - 'main.mp4', - ].join(''); - - const error = new shaka.util.Error( - shaka.util.Error.Severity.CRITICAL, - shaka.util.Error.Category.MANIFEST, - Code.HLS_AES_128_ENCRYPTION_NOT_SUPPORTED); - - await verifyError(master, media, error); - }); - describe('if required attributes are missing', () => { /** * @param {string} master diff --git a/test/media/streaming_engine_unit.js b/test/media/streaming_engine_unit.js index 2a5e1beefb..46032f559c 100644 --- a/test/media/streaming_engine_unit.js +++ b/test/media/streaming_engine_unit.js @@ -100,10 +100,13 @@ describe('StreamingEngine', () => { jasmine.clock().mockDate(); }); - /** @param {boolean=} trickMode + /** + * @param {boolean=} trickMode * @param {number=} mediaOffset The offset from 0 for the segment start times + * @param {shaka.extern.HlsAes128Key=} hlsAes128Key The AES-128 key to put in + * the manifest, if one should exist */ - function setupVod(trickMode, mediaOffset) { + function setupVod(trickMode, mediaOffset, hlsAes128Key) { // For VOD, we fake a presentation that has 2 Periods of equal duration // (20 seconds), where each Period has 1 Variant and 1 text stream. // @@ -209,7 +212,8 @@ describe('StreamingEngine', () => { setupManifest( /* firstPeriodStartTime= */ 0, /* secondPeriodStartTime= */ 20, - /* presentationDuration= */ 40); + /* presentationDuration= */ 40, + hlsAes128Key); } function setupLive() { @@ -356,8 +360,15 @@ describe('StreamingEngine', () => { /* delays= */ netEngineDelays); } + /** + * @param {number} firstPeriodStartTime + * @param {number} secondPeriodStartTime + * @param {number} presentationDuration + * @param {shaka.extern.HlsAes128Key=} hlsAes128Key + */ function setupManifest( - firstPeriodStartTime, secondPeriodStartTime, presentationDuration) { + firstPeriodStartTime, secondPeriodStartTime, presentationDuration, + hlsAes128Key) { const segmentDurations = { audio: segmentData[ContentType.AUDIO].segmentDuration, video: segmentData[ContentType.VIDEO].segmentDuration, @@ -380,7 +391,7 @@ describe('StreamingEngine', () => { /** @type {!shaka.media.PresentationTimeline} */(timeline), [firstPeriodStartTime, secondPeriodStartTime], presentationDuration, segmentDurations, initSegmentRanges, - timestampOffsets); + timestampOffsets, hlsAes128Key); audioStream = manifest.variants[0].audio; videoStream = manifest.variants[0].video; @@ -3430,6 +3441,69 @@ describe('StreamingEngine', () => { expect(alternateVideoStream.createSegmentIndex).not.toHaveBeenCalled(); }); + describe('AES-128', () => { + let key; + /** @type {!shaka.extern.HlsAes128Key} */ + let hlsAes128Key; + + beforeEach(async () => { + // Get a key. + const keyData = new ArrayBuffer(16); + const keyDataView = new DataView(keyData); + keyDataView.setInt16(0, 31710); // 0111 1011 1101 1110 + key = await window.crypto.subtle.importKey( + 'raw', keyData, 'AES-CBC', true, ['decrypt']); + + // Set up a manifest with AES-128 key info. + // We don't actually provide the imported key OR the key fetching function + // here, though, so that the individual tests can choose what the starting + // state of the hlsAes128Key object is. + hlsAes128Key = {method: 'AES-128', firstMediaSequenceNumber: 0}; + + setupVod(false, 0, hlsAes128Key); + mediaSourceEngine = new shaka.test.FakeMediaSourceEngine(segmentData); + presentationTimeInSeconds = 0; + createStreamingEngine(); + streamingEngine.switchVariant(variant); + }); + + afterEach(async () => { + await streamingEngine.destroy(); + }); + + async function runTest() { + spyOn(window.crypto.subtle, 'decrypt').and.callThrough(); + + await streamingEngine.start(); + playing = true; + await Util.fakeEventLoop(10); + + expect(mediaSourceEngine.appendBuffer).toHaveBeenCalledTimes(2); + expect(window.crypto.subtle.decrypt).toHaveBeenCalledTimes(2); + expect(window.crypto.subtle.decrypt).toHaveBeenCalledWith( + {name: 'AES-CBC', iv: jasmine.any(Object)}, key, jasmine.any(Object)); + } + + it('decrypts segments', async () => { + hlsAes128Key.cryptoKey = key; + await runTest(); + }); + + it('downloads key if not pre-filled', async () => { + hlsAes128Key.fetchKey = () => { + hlsAes128Key.cryptoKey = key; + hlsAes128Key.fetchKey = undefined; + return Promise.resolve(); + }; + + await runTest(); + + // The key should have been fetched. + expect(hlsAes128Key.cryptoKey).not.toBeUndefined(); + expect(hlsAes128Key.fetchKey).toBeUndefined(); + }); + }); + describe('destroy', () => { it('aborts pending network operations', async () => { setupVod(); diff --git a/test/test/util/simple_fakes.js b/test/test/util/simple_fakes.js index 12c9c94954..39f47d5f74 100644 --- a/test/test/util/simple_fakes.js +++ b/test/test/util/simple_fakes.js @@ -481,6 +481,10 @@ shaka.test.FakeSegmentIndex = class { return this.get(nextPosition - 1); }, + currentPosition: () => { + return nextPosition; + }, + seek: (time) => { nextPosition = this.find(time); return this.get(nextPosition++); diff --git a/test/test/util/streaming_engine_util.js b/test/test/util/streaming_engine_util.js index 5fb518fa4a..95ec24066f 100644 --- a/test/test/util/streaming_engine_util.js +++ b/test/test/util/streaming_engine_util.js @@ -149,11 +149,13 @@ shaka.test.StreamingEngineUtil = class { * ranges for each type of init segment. * @param {!Object.=} timestampOffsets The timestamp offset * for each type of segment + * @param {shaka.extern.HlsAes128Key=} hlsAes128Key The AES-128 key to provide + * to streams, if desired. * @return {shaka.extern.Manifest} */ static createManifest( presentationTimeline, periodStartTimes, presentationDuration, - segmentDurations, initSegmentRanges, timestampOffsets) { + segmentDurations, initSegmentRanges, timestampOffsets, hlsAes128Key) { const Util = shaka.test.Util; /** @@ -255,7 +257,7 @@ shaka.test.StreamingEngineUtil = class { const appendWindowEnd = periodIndex == periodStartTimes.length - 1? presentationDuration : periodStartTimes[periodIndex + 1]; - return new shaka.media.SegmentReference( + const ref = new shaka.media.SegmentReference( /* startTime= */ periodStart + positionWithinPeriod * d, /* endTime= */ periodStart + (positionWithinPeriod + 1) * d, getUris, @@ -265,6 +267,12 @@ shaka.test.StreamingEngineUtil = class { timestampOffset, appendWindowStart, appendWindowEnd); + const ContentType = shaka.util.ManifestParserUtils.ContentType; + if (hlsAes128Key && + (type == ContentType.AUDIO || type == ContentType.VIDEO)) { + ref.hlsAes128Key = hlsAes128Key; + } + return ref; }; /** @type {shaka.extern.Manifest} */