From 9701f5613d8a692ce1a50bd3703d7751c5c46da9 Mon Sep 17 00:00:00 2001 From: ldayananda Date: Thu, 28 Mar 2019 18:51:41 -0400 Subject: [PATCH 1/5] add integration test for cached encryption key --- test/test-helpers.js | 8 +++- test/videojs-http-streaming.test.js | 61 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) 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..0bae391cf 100644 --- a/test/videojs-http-streaming.test.js +++ b/test/videojs-http-streaming.test.js @@ -2275,6 +2275,67 @@ 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', 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(4); + + 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('seeking should abort an outstanding key request and create a new one', function(assert) { this.player.src({ src: 'https://example.com/encrypted.m3u8', From ff069231a1aebe8ac7a367d96c06b3800c3cf7a6 Mon Sep 17 00:00:00 2001 From: ldayananda Date: Thu, 28 Mar 2019 21:50:25 -0400 Subject: [PATCH 2/5] add unit tests --- test/configuration.test.js | 4 ++ test/master-playlist-controller.test.js | 35 ++++++++++++++++ test/media-segment-request.test.js | 53 ++++++++++++++++++++++++- test/videojs-http-streaming.test.js | 2 +- 4 files changed, 92 insertions(+), 2 deletions(-) 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/videojs-http-streaming.test.js b/test/videojs-http-streaming.test.js index 0bae391cf..4422c511a 100644 --- a/test/videojs-http-streaming.test.js +++ b/test/videojs-http-streaming.test.js @@ -2313,7 +2313,7 @@ QUnit.test('keys are not requested when cached key available', function(assert) this.standardXHRResponse(this.requests.shift(), new Uint32Array([1, 2, 3, 4])); // segment response this.standardXHRResponse(this.requests.shift()); - this.clock.tick(4); + this.clock.tick(1); const mainSegmentLoader = this.player.vhs.masterPlaylistController_.mainSegmentLoader_; const origHandleSegment = mainSegmentLoader.handleSegment_; From 56143a8d0d3b88a0a27c21c819efcc05ae4b3b3d Mon Sep 17 00:00:00 2001 From: ldayananda Date: Thu, 28 Mar 2019 22:56:28 -0400 Subject: [PATCH 3/5] adding segment-loader test --- test/segment-loader.test.js | 107 +++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/test/segment-loader.test.js b/test/segment-loader.test.js index 31cf1d6cd..d81b7c782 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,110 @@ QUnit.module('SegmentLoader: M2TS', function(hooks) { 'segment end time not shifted by mp4 start time'); }); + QUnit.test('segmentKey will cache new encrypted keys', 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('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); + + 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; From 1189a3d18731b4a94b098d7fc8187ca283a37232 Mon Sep 17 00:00:00 2001 From: ldayananda Date: Fri, 29 Mar 2019 10:45:53 -0400 Subject: [PATCH 4/5] adding some comments per CR and a negative case for cacheEncryptionKeys: false --- src/segment-loader.js | 4 +-- test/segment-loader.test.js | 38 ++++++++++++++++++++++++++++- test/videojs-http-streaming.test.js | 2 ++ 3 files changed, 41 insertions(+), 3 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/segment-loader.test.js b/test/segment-loader.test.js index d81b7c782..7b4c85c51 100644 --- a/test/segment-loader.test.js +++ b/test/segment-loader.test.js @@ -252,7 +252,7 @@ QUnit.module('SegmentLoader: M2TS', function(hooks) { 'segment end time not shifted by mp4 start time'); }); - QUnit.test('segmentKey will cache new encrypted keys', function(assert) { + 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, @@ -296,6 +296,40 @@ QUnit.module('SegmentLoader: M2TS', function(hooks) { ); }); + 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, { @@ -326,6 +360,8 @@ QUnit.module('SegmentLoader: M2TS', function(hooks) { 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_ = () => { diff --git a/test/videojs-http-streaming.test.js b/test/videojs-http-streaming.test.js index 4422c511a..a6db03a5d 100644 --- a/test/videojs-http-streaming.test.js +++ b/test/videojs-http-streaming.test.js @@ -2315,6 +2315,8 @@ QUnit.test('keys are not requested when cached key available', function(assert) 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_; From b57a4d01fe3ba82fa319408fc7eb658b62c08af5 Mon Sep 17 00:00:00 2001 From: ldayananda Date: Fri, 29 Mar 2019 10:53:18 -0400 Subject: [PATCH 5/5] negative test for cacheEncryptionKeys:false --- test/videojs-http-streaming.test.js | 70 ++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/test/videojs-http-streaming.test.js b/test/videojs-http-streaming.test.js index a6db03a5d..94f3720bb 100644 --- a/test/videojs-http-streaming.test.js +++ b/test/videojs-http-streaming.test.js @@ -2275,7 +2275,7 @@ 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', function(assert) { +QUnit.test('keys are not requested when cached key available, cacheEncryptionKeys:true', function(assert) { const done = assert.async(); this.player.src({ @@ -2338,6 +2338,74 @@ QUnit.test('keys are not requested when cached key available', function(assert) }; }); +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',