From f2c229955085c6a7bba91050977174a04380b635 Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Thu, 28 Mar 2019 14:03:33 -0400 Subject: [PATCH 1/7] add option to cache encrpytion keys in the player --- package-lock.json | 15 +++------- src/bin-utils.js | 7 +++++ src/master-playlist-controller.js | 6 ++-- src/media-segment-request.js | 8 +++-- src/segment-loader.js | 49 +++++++++++++++++++++++++++---- src/videojs-http-streaming.js | 1 + 6 files changed, 65 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 783b368f1..d9549e160 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3436,8 +3436,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.1.1", @@ -3667,7 +3666,6 @@ "version": "1.0.11", "bundled": true, "dev": true, - "optional": true, "requires": { "graceful-fs": "^4.1.2", "inherits": "~2.0.0", @@ -3735,8 +3733,7 @@ "graceful-fs": { "version": "4.1.11", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "har-schema": { "version": "1.0.5", @@ -3775,8 +3772,7 @@ "hoek": { "version": "2.16.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "http-signature": { "version": "1.1.1", @@ -3920,14 +3916,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "mkdirp": { "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -4209,7 +4203,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } diff --git a/src/bin-utils.js b/src/bin-utils.js index 546ff7ad5..6aea4b2e1 100644 --- a/src/bin-utils.js +++ b/src/bin-utils.js @@ -75,6 +75,13 @@ export const initSegmentId = function(initSegment) { ].join(','); }; +/** + * + */ +export const segmentKeyId = function(key) { + return key.resolvedUri; +}; + /** * utils to help dump binary data to the console */ diff --git a/src/master-playlist-controller.js b/src/master-playlist-controller.js index 2ba4e782c..1e743b794 100644 --- a/src/master-playlist-controller.js +++ b/src/master-playlist-controller.js @@ -64,7 +64,8 @@ export class MasterPlaylistController extends videojs.EventTarget { blacklistDuration, enableLowInitialPlaylist, sourceType, - seekTo + seekTo, + cacheEncryptionKeys } = options; if (!url) { @@ -125,7 +126,8 @@ export class MasterPlaylistController extends videojs.EventTarget { syncController: this.syncController_, decrypter: this.decrypter_, sourceType: this.sourceType_, - inbandTextTracks: this.inbandTextTracks_ + inbandTextTracks: this.inbandTextTracks_, + cacheEncryptionKeys }; this.masterPlaylistLoader_ = this.sourceType_ === 'dash' ? diff --git a/src/media-segment-request.js b/src/media-segment-request.js index 5ce2deb0b..1232fed0a 100644 --- a/src/media-segment-request.js +++ b/src/media-segment-request.js @@ -285,16 +285,18 @@ const decryptSegment = (decrypter, segment, doneFn) => { decrypter.addEventListener('message', decryptionHandler); + const keyBytes = segment.key.bytes.slice(); + // this is an encrypted segment // incrementally decrypt the segment decrypter.postMessage(createTransferableMessage({ source: segment.requestId, encrypted: segment.encryptedBytes, - key: segment.key.bytes, + key: keyBytes, iv: segment.key.iv }), [ segment.encryptedBytes.buffer, - segment.key.bytes.buffer + keyBytes.buffer ]); }; @@ -432,7 +434,7 @@ export const mediaSegmentRequest = (xhr, const finishProcessingFn = waitForCompletion(activeXhrs, decryptionWorker, doneFn); // optionally, request the decryption key - if (segment.key) { + if (segment.key && ! segment.key.bytes) { const keyRequestOptions = videojs.mergeOptions(xhrOptions, { uri: segment.key.resolvedUri, responseType: 'arraybuffer' diff --git a/src/segment-loader.js b/src/segment-loader.js index 4ea42b9cd..363ca9c3d 100644 --- a/src/segment-loader.js +++ b/src/segment-loader.js @@ -7,7 +7,7 @@ import SourceUpdater from './source-updater'; import Config from './config'; import window from 'global/window'; import { removeCuesFromTrack } from './mse/remove-cues-from-track'; -import { initSegmentId } from './bin-utils'; +import { initSegmentId, segmentKeyId } from './bin-utils'; import { mediaSegmentRequest, REQUEST_ERRORS } from './media-segment-request'; import { TIME_FUDGE_FACTOR, timeUntilRebuffer as timeUntilRebuffer_ } from './ranges'; import { minRebufferMaxBandwidthSelector } from './playlist-selectors'; @@ -183,6 +183,11 @@ export default class SegmentLoader extends videojs.EventTarget { // Fragmented mp4 playback this.activeInitSegmentId_ = null; this.initSegments_ = {}; + + // HLSe playback + this.cacheEncryptionKeys_ = settings.cacheEncryptionKeys; + this.keyCache_ = {}; + // Fmp4 CaptionParser this.captionParser_ = new CaptionParser(); @@ -355,6 +360,34 @@ export default class SegmentLoader extends videojs.EventTarget { return storedMap || map; } + /** + * Gets and sets key for the provided key + * + * @param {Object} key + * The key object representing the key to get or set + * @param {Boolean=} set + * If true, the key for the provided key should be saved + * @return {Object} + * Key object for desired key + */ + segmentKey(key, set = false) { + if (!key) { + return null; + } + + const id = segmentKeyId(key); + let storedKey = this.keyCache_[id]; + + if (this.cacheEncryptionKeys_ && set && !storedKey && key.bytes) { + this.keyCache_[id] = storedKey = { + resolvedUri: key.resolvedUri, + bytes: key.bytes + }; + } + + return storedKey || { resolvedUri: key.resolvedUri }; + } + /** * Returns true if all configuration required for loading is present, otherwise false. * @@ -1048,10 +1081,8 @@ export default class SegmentLoader extends videojs.EventTarget { 0, 0, 0, segmentInfo.mediaIndex + segmentInfo.playlist.mediaSequence ]); - simpleSegment.key = { - resolvedUri: segment.key.resolvedUri, - iv - }; + simpleSegment.key = this.segmentKey(segment.key); + simpleSegment.key.iv = iv; } if (segment.map) { @@ -1136,6 +1167,11 @@ export default class SegmentLoader extends videojs.EventTarget { simpleSegment.map = this.initSegment(simpleSegment.map, true); } + // if this request included a segment key, save that data in the cache + if (simpleSegment.key) { + this.segmentKey(simpleSegment.key, true); + } + this.processSegmentResponse_(simpleSegment); } @@ -1152,6 +1188,9 @@ export default class SegmentLoader extends videojs.EventTarget { if (simpleSegment.map) { segmentInfo.segment.map.bytes = simpleSegment.map.bytes; } + // if (simpleSegment.key) { + // segmentInfo.segment.key.bytes = simpleSegment.key.bytes; + // } segmentInfo.endOfAllRequests = simpleSegment.endOfAllRequests; diff --git a/src/videojs-http-streaming.js b/src/videojs-http-streaming.js index 63df06191..7b0ce4cbe 100644 --- a/src/videojs-http-streaming.js +++ b/src/videojs-http-streaming.js @@ -404,6 +404,7 @@ class HlsHandler extends Component { this.options_.useBandwidthFromLocalStorage || false; this.options_.customTagParsers = this.options_.customTagParsers || []; this.options_.customTagMappers = this.options_.customTagMappers || []; + this.options_.cacheEncryptionKeys = this.options_.cacheEncryptionKeys || false; if (typeof this.options_.blacklistDuration !== 'number') { this.options_.blacklistDuration = 5 * 60; From 5de9997b39b82093fc08e07815f0afd26bd3603f Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Thu, 28 Mar 2019 14:11:28 -0400 Subject: [PATCH 2/7] a bit of cleanup --- src/bin-utils.js | 2 +- src/media-segment-request.js | 2 +- src/segment-loader.js | 3 --- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/bin-utils.js b/src/bin-utils.js index 6aea4b2e1..8e0f3c591 100644 --- a/src/bin-utils.js +++ b/src/bin-utils.js @@ -76,7 +76,7 @@ export const initSegmentId = function(initSegment) { }; /** - * + * Returns a unique string identifier for a media segment key. */ export const segmentKeyId = function(key) { return key.resolvedUri; diff --git a/src/media-segment-request.js b/src/media-segment-request.js index 1232fed0a..f37c94737 100644 --- a/src/media-segment-request.js +++ b/src/media-segment-request.js @@ -434,7 +434,7 @@ export const mediaSegmentRequest = (xhr, const finishProcessingFn = waitForCompletion(activeXhrs, decryptionWorker, doneFn); // optionally, request the decryption key - if (segment.key && ! segment.key.bytes) { + if (segment.key && !segment.key.bytes) { const keyRequestOptions = videojs.mergeOptions(xhrOptions, { uri: segment.key.resolvedUri, responseType: 'arraybuffer' diff --git a/src/segment-loader.js b/src/segment-loader.js index 363ca9c3d..4d5fe2940 100644 --- a/src/segment-loader.js +++ b/src/segment-loader.js @@ -1188,9 +1188,6 @@ export default class SegmentLoader extends videojs.EventTarget { if (simpleSegment.map) { segmentInfo.segment.map.bytes = simpleSegment.map.bytes; } - // if (simpleSegment.key) { - // segmentInfo.segment.key.bytes = simpleSegment.key.bytes; - // } segmentInfo.endOfAllRequests = simpleSegment.endOfAllRequests; From dbde4be708fa105831b1c0df55eacad38911ffd9 Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Thu, 28 Mar 2019 14:14:37 -0400 Subject: [PATCH 3/7] add option to readme --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 2e97c36c0..6814a5df5 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Video.js Compatibility: 6.0, 7.0 - [Source](#source) - [List](#list) - [withCredentials](#withcredentials) + - [handleManifestRedirects](#handlemanifestredirects) - [useCueTags](#usecuetags) - [overrideNative](#overridenative) - [blacklistDuration](#blacklistduration) @@ -51,6 +52,7 @@ Video.js Compatibility: 6.0, 7.0 - [allowSeeksWithinUnsafeLiveWindow](#allowseekswithinunsafelivewindow) - [customTagParsers](#customtagparsers) - [customTagMappers](#customtagmappers) + - [cacheEncryptionKeys](#cacheencryptionkeys) - [Runtime Properties](#runtime-properties) - [hls.playlists.master](#hlsplaylistsmaster) - [hls.playlists.media](#hlsplaylistsmedia) @@ -418,6 +420,13 @@ With `customTagParsers` you can pass an array of custom m3u8 tag parser objects. Similar to `customTagParsers`, with `customTagMappers` you can pass an array of custom m3u8 tag mapper objects. See https://github.com/videojs/m3u8-parser#custom-parsers +##### cacheEncryptionKeys +* Type: `boolean` +* can be used as an initialization option + +This option forces the player to cache AES-128 encryption keys internally instead of requesting the key alongside every segment request. +This option defaults to `false`. + ### Runtime Properties Runtime properties are attached to the tech object when HLS is in use. You can get a reference to the HLS source handler like this: From e721740a796f2742fa6391a051b8702f05fa1f66 Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Thu, 28 Mar 2019 14:18:27 -0400 Subject: [PATCH 4/7] add as source option as well --- README.md | 1 + src/videojs-http-streaming.js | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6814a5df5..5c042d197 100644 --- a/README.md +++ b/README.md @@ -422,6 +422,7 @@ Similar to `customTagParsers`, with `customTagMappers` you can pass an array of ##### cacheEncryptionKeys * Type: `boolean` +* can be used as a source option * can be used as an initialization option This option forces the player to cache AES-128 encryption keys internally instead of requesting the key alongside every segment request. diff --git a/src/videojs-http-streaming.js b/src/videojs-http-streaming.js index 7b0ce4cbe..7b1e02fad 100644 --- a/src/videojs-http-streaming.js +++ b/src/videojs-http-streaming.js @@ -444,7 +444,8 @@ class HlsHandler extends Component { 'smoothQualityChange', 'customTagParsers', 'customTagMappers', - 'handleManifestRedirects' + 'handleManifestRedirects', + 'cacheEncryptionKeys' ].forEach((option) => { if (typeof this.source_[option] !== 'undefined') { this.options_[option] = this.source_[option]; From 57fe74c653ce146b830ea72da5472cf8fcae2999 Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Thu, 28 Mar 2019 15:23:54 -0400 Subject: [PATCH 5/7] add todo comment about HTTP Expires header --- src/segment-loader.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/segment-loader.js b/src/segment-loader.js index 4d5fe2940..05c08e3f9 100644 --- a/src/segment-loader.js +++ b/src/segment-loader.js @@ -347,6 +347,8 @@ export default class SegmentLoader extends videojs.EventTarget { const id = initSegmentId(map); let storedMap = this.initSegments_[id]; + // TODO: We should use the HTTP Expires header to invalidate our cache per + // https://tools.ietf.org/html/draft-pantos-http-live-streaming-23#section-6.2.3 if (set && !storedMap && map.bytes) { this.initSegments_[id] = storedMap = { resolvedUri: map.resolvedUri, From 13a749aa68336cd8c5fb86821f988b39ed32497a Mon Sep 17 00:00:00 2001 From: Matthew Neil Date: Thu, 28 Mar 2019 17:23:00 -0400 Subject: [PATCH 6/7] remove iv leak --- src/segment-loader.js | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/segment-loader.js b/src/segment-loader.js index 05c08e3f9..e82076e55 100644 --- a/src/segment-loader.js +++ b/src/segment-loader.js @@ -372,23 +372,31 @@ export default class SegmentLoader extends videojs.EventTarget { * @return {Object} * Key object for desired key */ - segmentKey(key, set = false) { - if (!key) { - return null; - } - - const id = segmentKeyId(key); - let storedKey = this.keyCache_[id]; - - if (this.cacheEncryptionKeys_ && set && !storedKey && key.bytes) { - this.keyCache_[id] = storedKey = { - resolvedUri: key.resolvedUri, - bytes: key.bytes - }; - } - - return storedKey || { resolvedUri: key.resolvedUri }; - } + segmentKey(key, set = false) { + if (!key) { + return null; + } + + const id = segmentKeyId(key); + let storedKey = this.keyCache_[id]; + + if (this.cacheEncryptionKeys_ && set && !storedKey && key.bytes) { + this.keyCache_[id] = storedKey = { + resolvedUri: key.resolvedUri, + bytes: key.bytes + }; + } + + const result = { + resolvedUri: (storedKey || key).resolvedUri + }; + + if (storedKey) { + result.bytes = storedKey.bytes; + } + + return result; + } /** * Returns true if all configuration required for loading is present, otherwise false. From 948a12242f6080a3c9eeb4573c790433e9df8aad Mon Sep 17 00:00:00 2001 From: ldayananda Date: Fri, 29 Mar 2019 10:59:50 -0400 Subject: [PATCH 7/7] add unit and integration tests (#448) * add integration test for cached encryption key * add unit tests * adding segment-loader test * adding some comments per CR and a negative case for cacheEncryptionKeys: false * negative test for cacheEncryptionKeys:false --- src/segment-loader.js | 4 +- test/configuration.test.js | 4 + test/master-playlist-controller.test.js | 35 ++++++ test/media-segment-request.test.js | 53 ++++++++- test/segment-loader.test.js | 143 +++++++++++++++++++++++- test/test-helpers.js | 8 +- test/videojs-http-streaming.test.js | 131 ++++++++++++++++++++++ 7 files changed, 372 insertions(+), 6 deletions(-) diff --git a/src/segment-loader.js b/src/segment-loader.js index e82076e55..1ce865548 100644 --- a/src/segment-loader.js +++ b/src/segment-loader.js @@ -347,8 +347,6 @@ export default class SegmentLoader extends videojs.EventTarget { const id = initSegmentId(map); let storedMap = this.initSegments_[id]; - // TODO: We should use the HTTP Expires header to invalidate our cache per - // https://tools.ietf.org/html/draft-pantos-http-live-streaming-23#section-6.2.3 if (set && !storedMap && map.bytes) { this.initSegments_[id] = storedMap = { resolvedUri: map.resolvedUri, @@ -380,6 +378,8 @@ export default class SegmentLoader extends videojs.EventTarget { const id = segmentKeyId(key); let storedKey = this.keyCache_[id]; + // TODO: We should use the HTTP Expires header to invalidate our cache per + // https://tools.ietf.org/html/draft-pantos-http-live-streaming-23#section-6.2.3 if (this.cacheEncryptionKeys_ && set && !storedKey && key.bytes) { this.keyCache_[id] = storedKey = { resolvedUri: key.resolvedUri, diff --git a/test/configuration.test.js b/test/configuration.test.js index 9cbac8050..152117f87 100644 --- a/test/configuration.test.js +++ b/test/configuration.test.js @@ -59,6 +59,10 @@ const options = [{ return `#FOO`; } }] +}, { + name: 'cacheEncryptionKeys', + default: false, + test: true }]; const CONFIG_KEYS = Object.keys(Config); diff --git a/test/master-playlist-controller.test.js b/test/master-playlist-controller.test.js index 903524071..d5c5de5ef 100644 --- a/test/master-playlist-controller.test.js +++ b/test/master-playlist-controller.test.js @@ -161,6 +161,41 @@ QUnit.test('creates appropriate PlaylistLoader for sourceType', function(assert) 'created a dash playlist loader'); }); +QUnit.test('passes options to SegmentLoader', function(assert) { + const options = { + url: 'test', + tech: this.player.tech_ + }; + + let controller = new MasterPlaylistController(options); + + assert.notOk(controller.mainSegmentLoader_.bandwidth, "bandwidth won't be set by default"); + assert.notOk(controller.mainSegmentLoader_.sourceType_, "sourceType won't be set by default"); + assert.notOk(controller.mainSegmentLoader_.cacheEncryptionKeys_, "cacheEncryptionKeys won't be set by default"); + + controller = new MasterPlaylistController(Object.assign({ + bandwidth: 3, + cacheEncryptionKeys: true, + sourceType: 'fake-type' + }, options)); + + assert.strictEqual( + controller.mainSegmentLoader_.bandwidth, + 3, + 'bandwidth will be set' + ); + assert.strictEqual( + controller.mainSegmentLoader_.sourceType_, + 'fake-type', + 'sourceType will be set' + ); + assert.strictEqual( + controller.mainSegmentLoader_.cacheEncryptionKeys_, + true, + 'cacheEncryptionKeys will be set' + ); +}); + QUnit.test('resets SegmentLoader when seeking out of buffer', function(assert) { let resets = 0; diff --git a/test/media-segment-request.test.js b/test/media-segment-request.test.js index e0042a607..1e1afb34b 100644 --- a/test/media-segment-request.test.js +++ b/test/media-segment-request.test.js @@ -294,7 +294,6 @@ QUnit.test('the key response is converted to the correct format', function(asser QUnit.test('segment with key has bytes decrypted', function(assert) { const done = assert.async(); - assert.expect(8); mediaSegmentRequest( this.xhr, this.xhrOptions, @@ -313,6 +312,12 @@ QUnit.test('segment with key has bytes decrypted', function(assert) { (error, segmentData) => { assert.notOk(error, 'there are no errors'); assert.ok(segmentData.bytes, 'decrypted bytes in segment'); + assert.ok(segmentData.key.bytes, 'key bytes in segment'); + assert.equal( + segmentData.key.bytes.buffer.byteLength, + 16, + 'key bytes are readable' + ); // verify stats assert.equal(segmentData.stats.bytesReceived, 8, '8 bytes'); @@ -336,6 +341,52 @@ QUnit.test('segment with key has bytes decrypted', function(assert) { this.clock.tick(100); }); +QUnit.test('segment with key bytes does not request key again', function(assert) { + const done = assert.async(); + + mediaSegmentRequest( + this.xhr, + this.xhrOptions, + this.realDecrypter, + this.noop, + { + resolvedUri: '0-test.ts', + key: { + resolvedUri: '0-key.php', + bytes: new Uint32Array([0, 2, 3, 1]), + iv: { + bytes: new Uint32Array([0, 0, 0, 1]) + } + } + }, + this.noop, + (error, segmentData) => { + assert.notOk(error, 'there are no errors'); + assert.ok(segmentData.bytes, 'decrypted bytes in segment'); + assert.ok(segmentData.key.bytes, 'key bytes in segment'); + assert.equal( + segmentData.key.bytes.buffer.byteLength, + 16, + 'key bytes are readable' + ); + + // verify stats + assert.equal(segmentData.stats.bytesReceived, 8, '8 bytes'); + done(); + }); + + assert.equal(this.requests.length, 1, 'there is one request'); + const segmentReq = this.requests.shift(); + + assert.equal(segmentReq.uri, '0-test.ts', 'the second request is for a segment'); + + segmentReq.response = new Uint8Array(8).buffer; + segmentReq.respond(200, null, ''); + + // Allow the decrypter to decrypt + this.clock.tick(100); +}); + QUnit.test('waits for every request to finish before the callback is run', function(assert) { const done = assert.async(); diff --git a/test/segment-loader.test.js b/test/segment-loader.test.js index 31cf1d6cd..7b4c85c51 100644 --- a/test/segment-loader.test.js +++ b/test/segment-loader.test.js @@ -8,7 +8,8 @@ import videojs from 'video.js'; import mp4probe from 'mux.js/lib/mp4/probe'; import { playlistWithDuration, - MockTextTrack + MockTextTrack, + standardXHRResponse } from './test-helpers.js'; import { LoaderCommonHooks, @@ -251,6 +252,146 @@ QUnit.module('SegmentLoader: M2TS', function(hooks) { 'segment end time not shifted by mp4 start time'); }); + QUnit.test('segmentKey will cache new encrypted keys with cacheEncryptionKeys true', function(assert) { + const newLoader = new SegmentLoader(LoaderCommonSettings.call(this, { + loaderType: 'main', + segmentMetadataTrack: this.segmentMetadataTrack, + cacheEncryptionKeys: true + }), {}); + + newLoader.playlist(playlistWithDuration(10), { isEncrypted: true }); + newLoader.mimeType(this.mimeType); + newLoader.load(); + this.clock.tick(1); + + assert.strictEqual( + Object.keys(newLoader.keyCache_).length, + 0, + 'no keys have been cached' + ); + + const result = newLoader.segmentKey({ + resolvedUri: 'key.php', + bytes: new Uint32Array([1, 2, 3, 4]) + }); + + assert.deepEqual( + result, + { resolvedUri: 'key.php' }, + 'gets by default' + ); + + newLoader.segmentKey( + { + resolvedUri: 'key.php', + bytes: new Uint32Array([1, 2, 3, 4]) + }, + true + ); + + assert.deepEqual( + newLoader.keyCache_['key.php'].bytes, + new Uint32Array([1, 2, 3, 4]), + 'key has been cached' + ); + }); + + QUnit.test('segmentKey will not cache encrypted keys with cacheEncryptionKeys false', function(assert) { + const newLoader = new SegmentLoader(LoaderCommonSettings.call(this, { + loaderType: 'main', + segmentMetadataTrack: this.segmentMetadataTrack, + cacheEncryptionKeys: false + }), {}); + + newLoader.playlist(playlistWithDuration(10), { isEncrypted: true }); + newLoader.mimeType(this.mimeType); + newLoader.load(); + this.clock.tick(1); + + assert.strictEqual( + Object.keys(newLoader.keyCache_).length, + 0, + 'no keys have been cached' + ); + + newLoader.segmentKey( + { + resolvedUri: 'key.php', + bytes: new Uint32Array([1, 2, 3, 4]) + }, + // set = true + true + ); + + assert.strictEqual( + Object.keys(newLoader.keyCache_).length, + 0, + 'no keys have been cached since cacheEncryptionKeys is false' + ); + }); + + QUnit.test('new segment requests will use cached keys', function(assert) { + const done = assert.async(); + const newLoader = new SegmentLoader(LoaderCommonSettings.call(this, { + loaderType: 'main', + segmentMetadataTrack: this.segmentMetadataTrack, + cacheEncryptionKeys: true + }), {}); + + newLoader.playlist(playlistWithDuration(20, { isEncrypted: true })); + // make the keys the same + newLoader.playlist_.segments[1].key = + videojs.mergeOptions({}, newLoader.playlist_.segments[0].key); + // give 2nd key an iv + newLoader.playlist_.segments[1].key.iv = new Uint32Array([0, 1, 2, 3]); + + newLoader.mimeType(this.mimeType); + newLoader.load(); + this.clock.tick(1); + + assert.strictEqual(this.requests.length, 2, 'two requests'); + assert.strictEqual(this.requests[0].uri, '0-key.php', 'key request'); + assert.strictEqual(this.requests[1].uri, '0.ts', 'segment request'); + + // key response + standardXHRResponse(this.requests.shift(), new Uint32Array([1, 1, 1, 1])); + this.clock.tick(1); + // segment + standardXHRResponse(this.requests.shift(), new Uint32Array([1, 5, 0, 1])); + this.clock.tick(1); + + // As the Decrypter is in a web worker, the last function in SegmentLoader is + // the easiest way to listen for the decrypted response + const origHandleSegment = newLoader.handleSegment_.bind(newLoader); + + newLoader.handleSegment_ = () => { + origHandleSegment(); + this.updateend(); + assert.deepEqual( + newLoader.keyCache_['0-key.php'], + { + resolvedUri: '0-key.php', + bytes: new Uint32Array([16777216, 16777216, 16777216, 16777216]) + }, + 'previous key was cached'); + + this.clock.tick(1); + assert.deepEqual( + newLoader.pendingSegment_.segment.key, + { + resolvedUri: '0-key.php', + uri: '0-key.php', + iv: new Uint32Array([0, 1, 2, 3]) + }, + 'used cached key for request and own initialization vector' + ); + + assert.strictEqual(this.requests.length, 1, 'one request'); + assert.strictEqual(this.requests[0].uri, '1.ts', 'only segment request'); + done(); + }; + }); + QUnit.test('triggers syncinfoupdate before attempting a resync', function(assert) { let syncInfoUpdates = 0; diff --git a/test/test-helpers.js b/test/test-helpers.js index bc3a0f8cc..33bd122f4 100644 --- a/test/test-helpers.js +++ b/test/test-helpers.js @@ -358,18 +358,22 @@ export const standardXHRResponse = function(request, data) { contentType = 'video/MP2T'; } else if (/\.mpd/.test(request.url)) { contentType = 'application/dash+xml'; + } else if (request.responseType === 'arraybuffer') { + contentType = 'binary/octet-stream'; } if (!data) { data = testDataManifests[manifestName]; } + const isTypedBuffer = data instanceof Uint8Array || data instanceof Uint32Array; + request.response = // if segment data was passed, use that, otherwise use a placeholder - data instanceof Uint8Array ? data.buffer : new Uint8Array(1024).buffer; + isTypedBuffer ? data.buffer : new Uint8Array(1024).buffer; request.respond(200, { 'Content-Type': contentType }, - data instanceof Uint8Array ? '' : data); + isTypedBuffer ? '' : data); }; export const playlistWithDuration = function(time, conf) { diff --git a/test/videojs-http-streaming.test.js b/test/videojs-http-streaming.test.js index cc137d1f3..94f3720bb 100644 --- a/test/videojs-http-streaming.test.js +++ b/test/videojs-http-streaming.test.js @@ -2275,6 +2275,137 @@ QUnit.test('keys are resolved relative to their containing playlist', function(a 'resolves multiple relative paths'); }); +QUnit.test('keys are not requested when cached key available, cacheEncryptionKeys:true', function(assert) { + const done = assert.async(); + + this.player.src({ + src: 'video/media-encrypted.m3u8', + type: 'application/vnd.apple.mpegurl', + cacheEncryptionKeys: true + }); + this.clock.tick(1); + + openMediaSource(this.player, this.clock); + this.requests.shift().respond(200, null, + '#EXTM3U\n' + + '#EXT-X-TARGETDURATION:15\n' + + '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php",IV=0x00000000000000000000000000000000\n' + + '#EXTINF:2.833,\n' + + 'http://media.example.com/fileSequence1.ts\n' + + '#EXTINF:2.833,\n' + + 'http://media.example.com/fileSequence2.ts\n' + + '#EXT-X-ENDLIST\n'); + this.clock.tick(1); + + assert.equal(this.requests.length, 2, 'requested a key'); + assert.equal( + this.requests[0].url, + absoluteUrl('video/keys/key.php'), + 'requested the key' + ); + assert.equal( + this.requests[1].url, + 'http://media.example.com/fileSequence1.ts', + 'requested the segment' + ); + + // key response + this.standardXHRResponse(this.requests.shift(), new Uint32Array([1, 2, 3, 4])); + // segment response + this.standardXHRResponse(this.requests.shift()); + this.clock.tick(1); + + // As the Decrypter is in a web worker, the last function in SegmentLoader is + // the easiest way to listen for the decrypted response + const mainSegmentLoader = this.player.vhs.masterPlaylistController_.mainSegmentLoader_; + const origHandleSegment = mainSegmentLoader.handleSegment_; + + mainSegmentLoader.handleSegment_ = () => { + origHandleSegment.call(mainSegmentLoader); + + this.player.tech_.hls.mediaSource.sourceBuffers[0].trigger('updateend'); + this.clock.tick(1); + + assert.equal(this.requests.length, 1, 'requested a segment, not a key'); + assert.equal( + this.requests[0].url, + absoluteUrl('http://media.example.com/fileSequence2.ts'), + 'requested the segment only' + ); + + mainSegmentLoader.handleSegment_ = origHandleSegment; + done(); + }; +}); + +QUnit.test('keys are requested per segment, cacheEncryptionKeys:false', function(assert) { + const done = assert.async(); + + this.player.src({ + src: 'video/media-encrypted.m3u8', + type: 'application/vnd.apple.mpegurl', + cacheEncryptionKeys: false + }); + this.clock.tick(1); + + openMediaSource(this.player, this.clock); + this.requests.shift().respond(200, null, + '#EXTM3U\n' + + '#EXT-X-TARGETDURATION:15\n' + + '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php",IV=0x00000000000000000000000000000000\n' + + '#EXTINF:2.833,\n' + + 'http://media.example.com/fileSequence1.ts\n' + + '#EXTINF:2.833,\n' + + 'http://media.example.com/fileSequence2.ts\n' + + '#EXT-X-ENDLIST\n'); + this.clock.tick(1); + + assert.equal(this.requests.length, 2, 'requested a key and segment'); + assert.equal( + this.requests[0].url, + absoluteUrl('video/keys/key.php'), + 'requested the key' + ); + assert.equal( + this.requests[1].url, + 'http://media.example.com/fileSequence1.ts', + 'requested the segment' + ); + + // key response + this.standardXHRResponse(this.requests.shift(), new Uint32Array([1, 2, 3, 4])); + // segment response + this.standardXHRResponse(this.requests.shift()); + this.clock.tick(1); + + // As the Decrypter is in a web worker, the last function in SegmentLoader is + // the easiest way to listen for the decrypted response + const mainSegmentLoader = this.player.vhs.masterPlaylistController_.mainSegmentLoader_; + const origHandleSegment = mainSegmentLoader.handleSegment_; + + mainSegmentLoader.handleSegment_ = () => { + origHandleSegment.call(mainSegmentLoader); + + this.player.tech_.hls.mediaSource.sourceBuffers[0].trigger('updateend'); + this.clock.tick(1); + + assert.equal(this.requests.length, 2, 'requested a segment and a key'); + assert.equal( + this.requests[0].url, + absoluteUrl('video/keys/key.php'), + 'requested the key again' + ); + assert.equal( + this.requests[1].url, + absoluteUrl('http://media.example.com/fileSequence2.ts'), + 'requested the segment' + ); + + mainSegmentLoader.handleSegment_ = origHandleSegment; + done(); + }; +}); + QUnit.test('seeking should abort an outstanding key request and create a new one', function(assert) { this.player.src({ src: 'https://example.com/encrypted.m3u8',