diff --git a/karma.conf.js b/karma.conf.js index 6574d2dd09..0373a81bc4 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -236,6 +236,7 @@ module.exports = (config) => { {pattern: 'test/test/assets/dash-multi-codec/*', included: false}, {pattern: 'test/test/assets/3675/*', included: false}, {pattern: 'test/test/assets/dash-aes-128/*', included: false}, + {pattern: 'test/test/assets/hls-aes-256/*', included: false}, {pattern: 'test/test/assets/hls-raw-aac/*', included: false}, {pattern: 'test/test/assets/hls-raw-ac3/*', included: false}, {pattern: 'test/test/assets/hls-raw-ec3/*', included: false}, diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index b90b898805..65782477b6 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -1300,7 +1300,7 @@ shaka.hls.HlsParser = class { if (sessionKeyTags.length > 0) { for (const drmTag of sessionKeyTags) { const method = drmTag.getRequiredAttrValue('METHOD'); - if (method != 'NONE' && method != 'AES-128') { + if (method != 'NONE' && !this.isAesMethod_(method)) { // According to the HLS spec, KEYFORMAT is optional and implicitly // defaults to "identity". // https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-11#section-4.4.4.4 @@ -2737,7 +2737,7 @@ shaka.hls.HlsParser = class { if (method != 'NONE') { encrypted = true; - if (method == 'AES-128') { + if (this.isAesMethod_(method)) { // These keys are handled separately. aesEncrypted = true; } else { @@ -2775,11 +2775,11 @@ shaka.hls.HlsParser = class { * @return {!shaka.extern.aesKey} * @private */ - parseAES128DrmTag_(drmTag, playlist, getUris, variables) { + parseAESDrmTag_(drmTag, playlist, getUris, variables) { // 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)'); + 'AES. (Web Crypto only exists in secure origins like https)'); throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, @@ -2810,6 +2810,7 @@ shaka.hls.HlsParser = class { } } + // Default AES-128 const keyInfo = { bitsKey: 128, blockCipherMode: 'CBC', @@ -2817,6 +2818,17 @@ shaka.hls.HlsParser = class { firstMediaSequenceNumber, }; + const method = drmTag.getRequiredAttrValue('METHOD'); + switch (method) { + case 'AES-256': + keyInfo.bitsKey = 256; + break; + case 'AES-256-CTR': + keyInfo.bitsKey = 256; + keyInfo.blockCipherMode = 'CTR'; + break; + } + // 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 () => { @@ -2829,7 +2841,8 @@ shaka.hls.HlsParser = class { 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) { + if (!keyResponse.data || + keyResponse.data.byteLength != (keyInfo.bitsKey / 8)) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, @@ -2837,7 +2850,8 @@ shaka.hls.HlsParser = class { } const algorithm = { - name: 'AES-CBC', + name: keyInfo.blockCipherMode == 'CTR' ? 'AES-CTR' : 'AES-CBC', + length: keyInfo.bitsKey, }; keyInfo.cryptoKey = await window.crypto.subtle.importKey( 'raw', keyResponse.data, algorithm, true, ['decrypt']); @@ -3029,10 +3043,10 @@ shaka.hls.HlsParser = class { let byteRangeTag = null; for (const tag of tags) { if (tag.name == 'EXT-X-KEY') { - if (tag.getRequiredAttrValue('METHOD') == 'AES-128' && + if (this.isAesMethod_(tag.getRequiredAttrValue('METHOD')) && tag.id < mapTag.id) { aesKey = - this.parseAES128DrmTag_(tag, playlist, getUris, variables); + this.parseAESDrmTag_(tag, playlist, getUris, variables); } } else if (tag.name == 'EXT-X-BYTERANGE' && tag.id < mapTag.id) { byteRangeTag = tag; @@ -3072,7 +3086,7 @@ shaka.hls.HlsParser = class { endByte = startByte + byteLength - 1; if (aesKey) { - // MAP segment encrypted with method 'AES-128', when served with + // MAP segment encrypted with method AES, when served with // HTTP Range, has the unencrypted size specified in the range. // See: https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-08#section-6.3.6 const length = (endByte + 1) - startByte; @@ -3478,12 +3492,12 @@ shaka.hls.HlsParser = class { discontinuitySequence++; } - // Apply new AES-128 tags as you see them, keeping a running total. + // Apply new AES tags as you see them, keeping a running total. for (const drmTag of item.tags) { if (drmTag.name == 'EXT-X-KEY') { - if (drmTag.getRequiredAttrValue('METHOD') == 'AES-128') { + if (this.isAesMethod_(drmTag.getRequiredAttrValue('METHOD'))) { aesKey = - this.parseAES128DrmTag_(drmTag, playlist, getUris, variables); + this.parseAESDrmTag_(drmTag, playlist, getUris, variables); } else { aesKey = undefined; } @@ -3988,6 +4002,17 @@ shaka.hls.HlsParser = class { return op.promise; } + /** + * @param {string} method + * @return {boolean} + * @private + */ + isAesMethod_(method) { + return method == 'AES-128' || + method == 'AES-256' || + method == 'AES-256-CTR'; + } + /** * @param {!shaka.hls.Tag} drmTag * @param {string} mimeType diff --git a/lib/media/streaming_engine.js b/lib/media/streaming_engine.js index c4e4beef30..56d00481f7 100644 --- a/lib/media/streaming_engine.js +++ b/lib/media/streaming_engine.js @@ -1567,7 +1567,9 @@ shaka.media.StreamingEngine = class { algorithm = { name: 'AES-CTR', counter: iv, - length: aesKey.bitsKey, + // NIST SP800-38A standard suggests that the counter should occupy half + // of the counter block + length: 64, }; } return window.crypto.subtle.decrypt(algorithm, key.cryptoKey, rawResult); diff --git a/test/hls/hls_parser_integration.js b/test/hls/hls_parser_integration.js new file mode 100644 index 0000000000..19f369f913 --- /dev/null +++ b/test/hls/hls_parser_integration.js @@ -0,0 +1,105 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * For unknown reasons, these tests fail in the test labs for Edge on Windows, + * in ways that do not seem to be unrelated to HLS parser. + * Practical testing has not found any sign that playback is actually broken in + * Edge, so these tests are disabled on Edge for the time being. + * TODO(#5834): Remove this filter once the tests are fixed. + * @return {boolean} + */ +function checkNoBrokenEdgeHls() { + const chromeVersion = shaka.util.Platform.chromeVersion(); + if (shaka.util.Platform.isWindows() && shaka.util.Platform.isEdge() && + chromeVersion && chromeVersion <= 122) { + // When the tests fail, it's due to the manifest parser failing to find a + // factory. Attempt to find a factory first, to avoid filtering the tests + // when running in a non-broken Edge environment. + const uri = 'fakeuri.m3u8'; + const mimeType = 'application/x-mpegurl'; + /* eslint-disable no-restricted-syntax */ + try { + shaka.media.ManifestParser.getFactory(uri, mimeType); + return true; + } catch (error) { + return false; + } + /* eslint-enable no-restricted-syntax */ + } + return true; +} + +filterDescribe('HlsParser', checkNoBrokenEdgeHls, () => { + const Util = shaka.test.Util; + + /** @type {!jasmine.Spy} */ + let onErrorSpy; + + /** @type {!HTMLVideoElement} */ + let video; + /** @type {shaka.Player} */ + let player; + /** @type {!shaka.util.EventManager} */ + let eventManager; + + let compiledShaka; + + /** @type {!shaka.test.Waiter} */ + let waiter; + + beforeAll(async () => { + video = shaka.test.UiUtils.createVideoElement(); + document.body.appendChild(video); + compiledShaka = + await shaka.test.Loader.loadShaka(getClientArg('uncompiled')); + }); + + beforeEach(async () => { + await shaka.test.TestScheme.createManifests(compiledShaka, '_compiled'); + player = new compiledShaka.Player(); + await player.attach(video); + + player.configure('streaming.useNativeHlsOnSafari', false); + + // Disable stall detection, which can interfere with playback tests. + player.configure('streaming.stallEnabled', false); + + // Grab event manager from the uncompiled library: + eventManager = new shaka.util.EventManager(); + waiter = new shaka.test.Waiter(eventManager); + waiter.setPlayer(player); + + onErrorSpy = jasmine.createSpy('onError'); + onErrorSpy.and.callFake((event) => fail(event.detail)); + eventManager.listen(player, 'error', Util.spyFunc(onErrorSpy)); + }); + + afterEach(async () => { + eventManager.release(); + await player.destroy(); + }); + + afterAll(() => { + document.body.removeChild(video); + }); + + it('supports AES-256 streaming', async () => { + await player.load('/base/test/test/assets/hls-aes-256/index.m3u8'); + await video.play(); + expect(player.isLive()).toBe(false); + + // Wait for the video to start playback. If it takes longer than 10 + // seconds, fail the test. + await waiter.waitForMovementOrFailOnTimeout(video, 10); + + // Play for 10 seconds, but stop early if the video ends. If it takes + // longer than 30 seconds, fail the test. + await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 10, 30); + + await player.unload(); + }); +}); diff --git a/test/test/assets/hls-aes-256/index.m3u8 b/test/test/assets/hls-aes-256/index.m3u8 new file mode 100644 index 0000000000..ccfa7567d4 --- /dev/null +++ b/test/test/assets/hls-aes-256/index.m3u8 @@ -0,0 +1,6 @@ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-INDEPENDENT-SEGMENTS + +#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=829339,BANDWIDTH=901770,CODECS="avc1.4D400D",RESOLUTION=320x180 +media.m3u8 \ No newline at end of file diff --git a/test/test/assets/hls-aes-256/init.key b/test/test/assets/hls-aes-256/init.key new file mode 100644 index 0000000000..dce1560969 --- /dev/null +++ b/test/test/assets/hls-aes-256/init.key @@ -0,0 +1 @@ +lt ^z`vpE_Kʇ2`bApTz \ No newline at end of file diff --git a/test/test/assets/hls-aes-256/init.mp4 b/test/test/assets/hls-aes-256/init.mp4 new file mode 100644 index 0000000000..a93c5dca31 Binary files /dev/null and b/test/test/assets/hls-aes-256/init.mp4 differ diff --git a/test/test/assets/hls-aes-256/media.m3u8 b/test/test/assets/hls-aes-256/media.m3u8 new file mode 100644 index 0000000000..1aaec2362b --- /dev/null +++ b/test/test/assets/hls-aes-256/media.m3u8 @@ -0,0 +1,31 @@ +#EXTM3U +#EXT-X-TARGETDURATION:4 +#EXT-X-VERSION:7 +#EXT-X-MEDIA-SEQUENCE:1 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-INDEPENDENT-SEGMENTS +#EXT-X-KEY:METHOD=NONE +#EXT-X-KEY:METHOD=AES-256,URI="init.key",IV=0xF9EBE119F65FCF2E5F3FDEEB4A3EB3D5 +#EXT-X-MAP:URI="init.mp4" + +#EXT-X-KEY:METHOD=AES-256,URI="seg0.key",IV=0x3F6DE518DE9A0D8B0FC5EA114ACEDEFA +#EXTINF:4.0 +segment_0.m4s + +#EXT-X-KEY:METHOD=AES-256,URI="seg1.key",IV=0x9DF0B659F52F23C3676C08FE0049500F +#EXTINF:4.0 +segment_1.m4s + +#EXT-X-KEY:METHOD=AES-256-CTR,URI="seg2.key",IV=0xDB67562D1B5F959D1DBA9DD50BF87A52 +#EXTINF:4.0 +segment_2.m4s + +#EXT-X-KEY:METHOD=AES-256-CTR,URI="seg3_and_4.key",IV=0x5161DD0995837650DE5E495DEF6BFBE5 +#EXTINF:4.0 +segment_3.m4s + +#EXT-X-KEY:METHOD=AES-256-CTR,URI="seg3_and_4.key",IV=0x035022245B6DBA858C56F2F0079BAC96 +#EXTINF:4.0 +segment_4.m4s + +#EXT-X-ENDLIST diff --git a/test/test/assets/hls-aes-256/seg0.key b/test/test/assets/hls-aes-256/seg0.key new file mode 100644 index 0000000000..e545247b34 --- /dev/null +++ b/test/test/assets/hls-aes-256/seg0.key @@ -0,0 +1 @@ +d-EEI)$vf@$H2I \ No newline at end of file diff --git a/test/test/assets/hls-aes-256/seg1.key b/test/test/assets/hls-aes-256/seg1.key new file mode 100644 index 0000000000..16c6672d34 --- /dev/null +++ b/test/test/assets/hls-aes-256/seg1.key @@ -0,0 +1 @@ +m]$_\݈fzCCX!k \ No newline at end of file diff --git a/test/test/assets/hls-aes-256/seg2.key b/test/test/assets/hls-aes-256/seg2.key new file mode 100644 index 0000000000..5a29eb6f79 --- /dev/null +++ b/test/test/assets/hls-aes-256/seg2.key @@ -0,0 +1,2 @@ +pf/`~ +G41ZKm"A=qykH \ No newline at end of file diff --git a/test/test/assets/hls-aes-256/seg3_and_4.key b/test/test/assets/hls-aes-256/seg3_and_4.key new file mode 100644 index 0000000000..5be1494786 --- /dev/null +++ b/test/test/assets/hls-aes-256/seg3_and_4.key @@ -0,0 +1 @@ +C`IYS