Skip to content

Commit

Permalink
feat: add option to cache encrpytion keys in the player (#446)
Browse files Browse the repository at this point in the history
Fixes #140
  • Loading branch information
mjneil authored and gkatsev committed Apr 1, 2019
1 parent 6e6c8c2 commit 599b94d
Show file tree
Hide file tree
Showing 13 changed files with 454 additions and 26 deletions.
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -418,6 +420,14 @@ 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 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.
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:
Expand Down
15 changes: 4 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions src/bin-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ export const initSegmentId = function(initSegment) {
].join(',');
};

/**
* Returns a unique string identifier for a media segment key.
*/
export const segmentKeyId = function(key) {
return key.resolvedUri;
};

/**
* utils to help dump binary data to the console
*/
Expand Down
6 changes: 4 additions & 2 deletions src/master-playlist-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ export class MasterPlaylistController extends videojs.EventTarget {
blacklistDuration,
enableLowInitialPlaylist,
sourceType,
seekTo
seekTo,
cacheEncryptionKeys
} = options;

if (!url) {
Expand Down Expand Up @@ -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' ?
Expand Down
8 changes: 5 additions & 3 deletions src/media-segment-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
]);
};

Expand Down Expand Up @@ -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'
Expand Down
56 changes: 51 additions & 5 deletions src/segment-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -355,6 +360,44 @@ 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];

// 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,
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.
*
Expand Down Expand Up @@ -1048,10 +1091,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) {
Expand Down Expand Up @@ -1136,6 +1177,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);
}

Expand Down
4 changes: 3 additions & 1 deletion src/videojs-http-streaming.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -443,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];
Expand Down
4 changes: 4 additions & 0 deletions test/configuration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ const options = [{
return `#FOO`;
}
}]
}, {
name: 'cacheEncryptionKeys',
default: false,
test: true
}];

const CONFIG_KEYS = Object.keys(Config);
Expand Down
35 changes: 35 additions & 0 deletions test/master-playlist-controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
53 changes: 52 additions & 1 deletion test/media-segment-request.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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');
Expand All @@ -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();
Expand Down
Loading

0 comments on commit 599b94d

Please sign in to comment.