Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add option to cache encrpytion keys in the player #446

Merged
merged 7 commits into from
Apr 1, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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