ee937d81 by David LaPalomento

Merge pull request #139 from videojs/feature/hlse2

Support segment-level AES-128 encryption
2 parents bf982b0b 93113083
......@@ -74,6 +74,7 @@ module.exports = function(grunt) {
connect: {
dev: {
options: {
hostname: '*',
port: 9999,
keepalive: true
}
......
......@@ -10,4 +10,40 @@ Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
\ No newline at end of file
limitations under the License.
The AES decryption implementation in this project is derived from the
Stanford Javascript Cryptography Library
(http://bitwiseshiftleft.github.io/sjcl/). That work is covered by the
following copyright and permission notice:
Copyright 2009-2010 Emily Stark, Mike Hamburg, Dan Boneh.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
The views and conclusions contained in the software and documentation
are those of the authors and should not be interpreted as representing
official policies, either expressed or implied, of the authors.
\ No newline at end of file
......
# Encrypted HTTP Live Streaming
The [HLS spec](http://tools.ietf.org/html/draft-pantos-http-live-streaming-13#section-6.2.3) requires segments to be encrypted with AES-128 in CBC mode with PKCS7 padding. You can encrypt data to that specification with a combination of [OpenSSL](https://www.openssl.org/) and the [pkcs7 utility](https://github.com/brightcove/pkcs7). From the command-line:
```sh
# encrypt the text "hello" into a file
# since this is for testing, skip the key salting so the output is stable
# using -nosalt outside of testing is a terrible idea!
echo -n "hello" | pkcs7 | \
openssl enc -aes-128-cbc -nopad -nosalt -K $KEY -iv $IV > hello.encrypted
# xxd is a handy way of translating binary into a format easily consumed by
# javascript
xxd -i hello.encrypted
```
Later, you can decrypt it:
```sh
openssl enc -d -nopad -aes-128-cbc -K $KEY -iv $IV
```
......@@ -27,6 +27,11 @@
<script src="src/stream.js"></script>
<script src="src/m3u8/m3u8-parser.js"></script>
<script src="src/playlist-loader.js"></script>
<script src="node_modules/pkcs7/dist/pkcs7.unpad.js"></script>
<script src="src/decrypter.js"></script>
<script src="src/bin-utils.js"></script>
<!-- example MPEG2-TS segments -->
<!-- bipbop -->
......@@ -37,12 +42,14 @@
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
}
.info {
background-color: #eee;
border: thin solid #333;
border-radius: 3px;
padding: 0 5px;
margin: 20px 0;
}
</style>
......
......@@ -37,6 +37,7 @@
"video.js": "^4.7.2"
},
"dependencies": {
"pkcs7": "^0.2.2",
"videojs-contrib-media-sources": "^0.3.0"
}
}
......
......@@ -4,12 +4,12 @@
var
bytes = Array.prototype.slice.call(data),
step = 16,
formatHexString = function(e) {
formatHexString = function(e, i) {
var value = e.toString(16);
return "00".substring(0, 2 - value.length) + value;
return "00".substring(0, 2 - value.length) + value + (i % 2 ? ' ' : '');
},
formatAsciiString = function(e) {
if (e > 32 && e < 125) {
if (e >= 0x20 && e < 0x7e) {
return String.fromCharCode(e);
}
return '.';
......@@ -18,9 +18,9 @@
hex,
ascii;
for (var j = 0; j < bytes.length / step; j++) {
hex = bytes.slice(j * step, j * step + step).map(formatHexString).join(' ');
hex = bytes.slice(j * step, j * step + step).map(formatHexString).join('');
ascii = bytes.slice(j * step, j * step + step).map(formatAsciiString).join('');
result += hex + ' ' + ascii + '\n';
result += hex + ' ' + ascii + '\n';
}
return result;
},
......
/*
* videojs-hls
*
* Copyright (c) 2014 Brightcove
* All rights reserved.
*
* This file contains an adaptation of the AES decryption algorithm
* from the Standford Javascript Cryptography Library. That work is
* covered by the following copyright and permissions notice:
*
* Copyright 2009-2010 Emily Stark, Mike Hamburg, Dan Boneh.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following
* disclaimer in the documentation and/or other materials provided
* with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
* BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
* OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
* IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* The views and conclusions contained in the software and documentation
* are those of the authors and should not be interpreted as representing
* official policies, either expressed or implied, of the authors.
*/
(function(window, videojs, unpad) {
'use strict';
var AES, decrypt;
/**
* Schedule out an AES key for both encryption and decryption. This
* is a low-level class. Use a cipher mode to do bulk encryption.
*
* @constructor
* @param key {Array} The key as an array of 4, 6 or 8 words.
*/
AES = function (key) {
this._precompute();
var i, j, tmp,
encKey, decKey,
sbox = this._tables[0][4], decTable = this._tables[1],
keyLen = key.length, rcon = 1;
if (keyLen !== 4 && keyLen !== 6 && keyLen !== 8) {
throw new Error("Invalid aes key size");
}
encKey = key.slice(0);
decKey = [];
this._key = [encKey, decKey];
// schedule encryption keys
for (i = keyLen; i < 4 * keyLen + 28; i++) {
tmp = encKey[i-1];
// apply sbox
if (i%keyLen === 0 || (keyLen === 8 && i%keyLen === 4)) {
tmp = sbox[tmp>>>24]<<24 ^ sbox[tmp>>16&255]<<16 ^ sbox[tmp>>8&255]<<8 ^ sbox[tmp&255];
// shift rows and add rcon
if (i%keyLen === 0) {
tmp = tmp<<8 ^ tmp>>>24 ^ rcon<<24;
rcon = rcon<<1 ^ (rcon>>7)*283;
}
}
encKey[i] = encKey[i-keyLen] ^ tmp;
}
// schedule decryption keys
for (j = 0; i; j++, i--) {
tmp = encKey[j&3 ? i : i - 4];
if (i<=4 || j<4) {
decKey[j] = tmp;
} else {
decKey[j] = decTable[0][sbox[tmp>>>24 ]] ^
decTable[1][sbox[tmp>>16 & 255]] ^
decTable[2][sbox[tmp>>8 & 255]] ^
decTable[3][sbox[tmp & 255]];
}
}
};
AES.prototype = {
/**
* The expanded S-box and inverse S-box tables. These will be computed
* on the client so that we don't have to send them down the wire.
*
* There are two tables, _tables[0] is for encryption and
* _tables[1] is for decryption.
*
* The first 4 sub-tables are the expanded S-box with MixColumns. The
* last (_tables[01][4]) is the S-box itself.
*
* @private
*/
_tables: [[[],[],[],[],[]],[[],[],[],[],[]]],
/**
* Expand the S-box tables.
*
* @private
*/
_precompute: function () {
var encTable = this._tables[0], decTable = this._tables[1],
sbox = encTable[4], sboxInv = decTable[4],
i, x, xInv, d=[], th=[], x2, x4, x8, s, tEnc, tDec;
// Compute double and third tables
for (i = 0; i < 256; i++) {
th[( d[i] = i<<1 ^ (i>>7)*283 )^i]=i;
}
for (x = xInv = 0; !sbox[x]; x ^= x2 || 1, xInv = th[xInv] || 1) {
// Compute sbox
s = xInv ^ xInv<<1 ^ xInv<<2 ^ xInv<<3 ^ xInv<<4;
s = s>>8 ^ s&255 ^ 99;
sbox[x] = s;
sboxInv[s] = x;
// Compute MixColumns
x8 = d[x4 = d[x2 = d[x]]];
tDec = x8*0x1010101 ^ x4*0x10001 ^ x2*0x101 ^ x*0x1010100;
tEnc = d[s]*0x101 ^ s*0x1010100;
for (i = 0; i < 4; i++) {
encTable[i][x] = tEnc = tEnc<<24 ^ tEnc>>>8;
decTable[i][s] = tDec = tDec<<24 ^ tDec>>>8;
}
}
// Compactify. Considerable speedup on Firefox.
for (i = 0; i < 5; i++) {
encTable[i] = encTable[i].slice(0);
decTable[i] = decTable[i].slice(0);
}
},
/**
* Decrypt an array of 4 big-endian words.
* @param {Array} data The ciphertext.
* @return {Array} The plaintext.
*/
decrypt:function (input) {
if (input.length !== 4) {
throw new Error("Invalid aes block size");
}
var key = this._key[1],
// state variables a,b,c,d are loaded with pre-whitened data
a = input[0] ^ key[0],
b = input[3] ^ key[1],
c = input[2] ^ key[2],
d = input[1] ^ key[3],
a2, b2, c2,
nInnerRounds = key.length/4 - 2,
i,
kIndex = 4,
out = [0,0,0,0],
table = this._tables[1],
// load up the tables
t0 = table[0],
t1 = table[1],
t2 = table[2],
t3 = table[3],
sbox = table[4];
// Inner rounds. Cribbed from OpenSSL.
for (i = 0; i < nInnerRounds; i++) {
a2 = t0[a>>>24] ^ t1[b>>16 & 255] ^ t2[c>>8 & 255] ^ t3[d & 255] ^ key[kIndex];
b2 = t0[b>>>24] ^ t1[c>>16 & 255] ^ t2[d>>8 & 255] ^ t3[a & 255] ^ key[kIndex + 1];
c2 = t0[c>>>24] ^ t1[d>>16 & 255] ^ t2[a>>8 & 255] ^ t3[b & 255] ^ key[kIndex + 2];
d = t0[d>>>24] ^ t1[a>>16 & 255] ^ t2[b>>8 & 255] ^ t3[c & 255] ^ key[kIndex + 3];
kIndex += 4;
a=a2; b=b2; c=c2;
}
// Last round.
for (i = 0; i < 4; i++) {
out[3 & -i] =
sbox[a>>>24 ]<<24 ^
sbox[b>>16 & 255]<<16 ^
sbox[c>>8 & 255]<<8 ^
sbox[d & 255] ^
key[kIndex++];
a2=a; a=b; b=c; c=d; d=a2;
}
return out;
}
};
decrypt = function(encrypted, key, initVector) {
var
encryptedView = new DataView(encrypted.buffer),
platformEndian = new Uint32Array(encrypted.byteLength / 4),
decipher = new AES(Array.prototype.slice.call(key)),
decrypted = new Uint8Array(encrypted.byteLength),
decryptedView = new DataView(decrypted.buffer),
decryptedBlock,
word,
byte;
// convert big-endian input to platform byte order for decryption
for (byte = 0; byte < encrypted.byteLength; byte += 4) {
platformEndian[byte >>> 2] = encryptedView.getUint32(byte);
}
// decrypt four word sequences, applying cipher-block chaining (CBC)
// to each decrypted block
for (word = 0; word < platformEndian.length; word += 4) {
// decrypt the block
decryptedBlock = decipher.decrypt(platformEndian.subarray(word, word + 4));
// XOR with the IV, and restore network byte-order to obtain the
// plaintext
byte = word << 2;
decryptedView.setUint32(byte, decryptedBlock[0] ^ initVector[0]);
decryptedView.setUint32(byte + 4, decryptedBlock[1] ^ initVector[1]);
decryptedView.setUint32(byte + 8, decryptedBlock[2] ^ initVector[2]);
decryptedView.setUint32(byte + 12, decryptedBlock[3] ^ initVector[3]);
// setup the IV for the next round
initVector = platformEndian.subarray(word, word + 4);
}
// remove any padding
return unpad(decrypted);
};
// exports
videojs.Hls.decrypt = decrypt;
})(window, window.videojs, window.pkcs7.unpad);
/**
* Utilities for parsing M3U8 files. If the entire manifest is available,
* `Parser` will create a object representation with enough detail for managing
* `Parser` will create an object representation with enough detail for managing
* playback. `ParseStream` and `LineStream` are lower-level parsing primitives
* that do not assume the entirety of the manifest is ready and expose a
* ReadableStream-like interface.
......@@ -8,27 +8,41 @@
(function(videojs, parseInt, isFinite, mergeOptions, undefined) {
var
noop = function() {},
// "forgiving" attribute list psuedo-grammar:
// attributes -> keyvalue (',' keyvalue)*
// keyvalue -> key '=' value
// key -> [^=]*
// value -> '"' [^"]* '"' | [^,]*
attributeSeparator = (function() {
var
key = '[^=]*',
value = '"[^"]*"|[^,]*',
keyvalue = '(?:' + key + ')=(?:' + value + ')';
return new RegExp('(?:^|,)(' + keyvalue + ')');
})(),
parseAttributes = function(attributes) {
var
attrs = attributes.split(','),
// split the string using attributes as the separator
attrs = attributes.split(attributeSeparator),
i = attrs.length,
result = {},
attr;
while (i--) {
attr = attrs[i].split('=');
attr[0] = attr[0].replace(/^\s+|\s+$/g, '');
// This is not sexy, but gives us the resulting object we want.
if (attr[1]) {
attr[1] = attr[1].replace(/^\s+|\s+$/g, '');
if (attr[1].indexOf('"') !== -1) {
attr[1] = attr[1].split('"')[1];
}
result[attr[0]] = attr[1];
} else {
attrs[i - 1] = attrs[i - 1] + ',' + attr[0];
// filter out unmatched portions of the string
if (attrs[i] === '') {
continue;
}
// split the key and value
attr = /([^=]*)=(.*)/.exec(attrs[i]).slice(1);
// trim whitespace and remove optional quotes around the value
attr[0] = attr[0].replace(/^\s+|\s+$/g, '');
attr[1] = attr[1].replace(/^\s+|\s+$/g, '');
attr[1] = attr[1].replace(/^['"](.*)['"]$/g, '$1');
result[attr[0]] = attr[1];
}
return result;
},
......@@ -281,6 +295,27 @@
});
return;
}
match = (/^#EXT-X-KEY:?(.*)$/).exec(line);
if (match) {
event = {
type: 'tag',
tagType: 'key'
};
if (match[1]) {
event.attributes = parseAttributes(match[1]);
// parse the IV string into a Uint32Array
if (event.attributes.IV) {
event.attributes.IV = event.attributes.IV.match(/.{8}/g);
event.attributes.IV[0] = parseInt(event.attributes.IV[0], 16);
event.attributes.IV[1] = parseInt(event.attributes.IV[1], 16);
event.attributes.IV[2] = parseInt(event.attributes.IV[2], 16);
event.attributes.IV[3] = parseInt(event.attributes.IV[3], 16);
event.attributes.IV = new Uint32Array(event.attributes.IV);
}
}
this.trigger('data', event);
return;
}
// unknown tag type
this.trigger('data', {
......@@ -311,7 +346,8 @@
var
self = this,
uris = [],
currentUri = {};
currentUri = {},
key;
Parser.prototype.init.call(this);
this.lineStream = new LineStream();
......@@ -373,6 +409,36 @@
this.manifest.segments = uris;
},
'key': function() {
if (!entry.attributes) {
this.trigger('warn', {
message: 'ignoring key declaration without attribute list'
});
return;
}
// clear the active encryption key
if (entry.attributes.METHOD === 'NONE') {
key = null;
return;
}
if (!entry.attributes.URI) {
this.trigger('warn', {
message: 'ignoring key declaration without URI'
});
return;
}
if (!entry.attributes.METHOD) {
this.trigger('warn', {
message: 'defaulting key method to AES-128'
});
}
// setup an encryption key for upcoming segments
key = {
method: entry.attributes.METHOD || 'AES-128',
uri: entry.attributes.URI
};
},
'media-sequence': function() {
if (!isFinite(entry.number)) {
this.trigger('warn', {
......@@ -442,6 +508,10 @@
});
currentUri.duration = this.manifest.targetDuration;
}
// annotate with encryption information, if necessary
if (key) {
currentUri.key = key;
}
// prepare for the next URI
currentUri = {};
......
......@@ -12,8 +12,15 @@ var
// a fudge factor to apply to advertised playlist bitrates to account for
// temporary flucations in client bandwidth
bandwidthVariance = 1.1,
keyXhr,
keyFailed,
resolveUrl;
// returns true if a key has failed to download within a certain amount of retries
keyFailed = function(key) {
return key.retries && key.retries >= 2;
};
videojs.Hls = videojs.Flash.extend({
init: function(player, options, ready) {
var
......@@ -116,11 +123,20 @@ videojs.Hls.prototype.handleSourceOpen = function() {
this.updateDuration(this.playlists.media());
this.mediaIndex = videojs.Hls.translateMediaIndex(this.mediaIndex, oldMediaPlaylist, updatedPlaylist);
oldMediaPlaylist = updatedPlaylist;
this.fetchKeys(updatedPlaylist, this.mediaIndex);
}));
this.playlists.on('mediachange', function() {
this.playlists.on('mediachange', videojs.bind(this, function() {
// abort outstanding key requests and check if new keys need to be retrieved
if (keyXhr) {
keyXhr.abort();
keyXhr = null;
this.fetchKeys(this.playlists.media(), this.mediaIndex);
}
player.trigger('mediachange');
});
}));
// if autoplay is enabled, begin playback. This is duplicative of
// code in video.js but is required because play() must be invoked
......@@ -175,6 +191,14 @@ videojs.Hls.prototype.setCurrentTime = function(currentTime) {
this.segmentXhr_.abort();
}
// fetch new encryption keys, if necessary
if (keyXhr) {
keyXhr.aborted = true;
keyXhr.abort();
keyXhr = null;
this.fetchKeys(this.playlists.media(), this.mediaIndex);
}
// clear out any buffered segments
this.segmentBuffer_ = [];
......@@ -212,6 +236,11 @@ videojs.Hls.prototype.dispose = function() {
this.segmentXhr_.onreadystatechange = null;
this.segmentXhr_.abort();
}
if (keyXhr) {
keyXhr.onreadystatechange = null;
keyXhr.abort();
keyXhr = null;
}
if (this.playlists) {
this.playlists.dispose();
}
......@@ -357,8 +386,6 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, offset) {
responseType: 'arraybuffer',
withCredentials: settings.withCredentials
}, function(error, url) {
var tags;
// the segment request is no longer outstanding
tech.segmentXhr_ = null;
......@@ -390,23 +417,15 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, offset) {
tech.bandwidth = (this.response.byteLength / tech.segmentXhrTime) * 8 * 1000;
tech.bytesReceived += this.response.byteLength;
// transmux the segment data from MP2T to FLV
tech.segmentParser_.parseSegmentBinaryData(new Uint8Array(this.response));
tech.segmentParser_.flushTags();
// package up all the work to append the segment
// if the segment is the start of a timestamp discontinuity,
// we have to wait until the sourcebuffer is empty before
// aborting the source buffer processing
tags = [];
while (tech.segmentParser_.tagsAvailable()) {
tags.push(tech.segmentParser_.getNextTag());
}
tech.segmentBuffer_.push({
mediaIndex: tech.mediaIndex,
playlist: tech.playlists.media(),
offset: offset,
tags: tags
bytes: new Uint8Array(this.response)
});
tech.drainBuffer();
......@@ -425,6 +444,7 @@ videojs.Hls.prototype.drainBuffer = function(event) {
playlist,
offset,
tags,
bytes,
segment,
ptsTime,
......@@ -438,9 +458,28 @@ videojs.Hls.prototype.drainBuffer = function(event) {
mediaIndex = segmentBuffer[0].mediaIndex;
playlist = segmentBuffer[0].playlist;
offset = segmentBuffer[0].offset;
tags = segmentBuffer[0].tags;
bytes = segmentBuffer[0].bytes;
segment = playlist.segments[mediaIndex];
if (segment.key) {
// this is an encrypted segment
// if the key download failed, we want to skip this segment
// but if the key hasn't downloaded yet, we want to try again later
if (keyFailed(segment.key)) {
return segmentBuffer.shift();
} else if (!segment.key.bytes) {
return;
} else {
// if the media sequence is greater than 2^32, the IV will be incorrect
// assuming 10s segments, that would be about 1300 years
bytes = videojs.Hls.decrypt(bytes,
segment.key.bytes,
new Uint32Array([
0, 0, 0,
mediaIndex + playlist.mediaSequence]));
}
}
event = event || {};
segmentOffset = videojs.Hls.getPlaylistDuration(playlist, 0, mediaIndex) * 1000;
......@@ -456,6 +495,15 @@ videojs.Hls.prototype.drainBuffer = function(event) {
this.el().vjs_setProperty('currentTime', segmentOffset * 0.001);
}
// transmux the segment data from MP2T to FLV
this.segmentParser_.parseSegmentBinaryData(bytes);
this.segmentParser_.flushTags();
tags = [];
while (this.segmentParser_.tagsAvailable()) {
tags.push(this.segmentParser_.getNextTag());
}
// if we're refilling the buffer after a seek, scan through the muxed
// FLV tags until we find the one that is closest to the desired
// playback time
......@@ -492,6 +540,53 @@ videojs.Hls.prototype.drainBuffer = function(event) {
}
};
videojs.Hls.prototype.fetchKeys = function(playlist, index) {
var i, key, tech, player, settings, view;
// if there is a pending XHR or no segments, don't do anything
if (keyXhr || !playlist.segments) {
return;
}
tech = this;
player = this.player();
settings = player.options().hls || {};
// jshint -W083
for (i = index; i < playlist.segments.length; i++) {
key = playlist.segments[i].key;
if (key && !key.bytes && !keyFailed(key)) {
keyXhr = videojs.Hls.xhr({
url: resolveUrl(playlist.uri, key.uri),
responseType: 'arraybuffer',
withCredentials: settings.withCredentials
}, function(err, url) {
keyXhr = null;
if (err || !this.response || this.response.byteLength !== 16) {
key.retries = key.retries || 0;
key.retries++;
if (!this.aborted) {
tech.fetchKeys(playlist, i);
}
return;
}
view = new DataView(this.response);
key.bytes = new Uint32Array([
view.getUint32(0),
view.getUint32(4),
view.getUint32(8),
view.getUint32(12)
]);
tech.fetchKeys(playlist, i++, url);
});
break;
}
}
// jshint +W083
};
/**
* Whether the browser has built-in HLS support.
*/
......
(function(window, videojs, undefined) {
'use strict';
/*
======== A Handy Little QUnit Reference ========
http://api.qunitjs.com/
Test methods:
module(name, {[setup][ ,teardown]})
test(name, callback)
expect(numberOfAssertions)
stop(increment)
start(decrement)
Test assertions:
ok(value, [message])
equal(actual, expected, [message])
notEqual(actual, expected, [message])
deepEqual(actual, expected, [message])
notDeepEqual(actual, expected, [message])
strictEqual(actual, expected, [message])
notStrictEqual(actual, expected, [message])
throws(block, [expected], [message])
*/
// see docs/hlse.md for instructions on how test data was generated
var stringFromBytes = function(bytes) {
var result = '', i;
for (i = 0; i < bytes.length; i++) {
result += String.fromCharCode(bytes[i]);
}
return result;
};
module('Decryption');
test('decrypts a single AES-128 with PKCS7 block', function() {
var
key = new Uint32Array([0, 0, 0, 0]),
initVector = key,
// the string "howdy folks" encrypted
encrypted = new Uint8Array([
0xce, 0x90, 0x97, 0xd0,
0x08, 0x46, 0x4d, 0x18,
0x4f, 0xae, 0x01, 0x1c,
0x82, 0xa8, 0xf0, 0x67]);
deepEqual('howdy folks',
stringFromBytes(videojs.Hls.decrypt(encrypted, key, initVector)),
'decrypted with a byte array key');
});
test('decrypts multiple AES-128 blocks with CBC', function() {
var
key = new Uint32Array([0, 0, 0, 0]),
initVector = key,
// the string "0123456789abcdef01234" encrypted
encrypted = new Uint8Array([
0x14, 0xf5, 0xfe, 0x74,
0x69, 0x66, 0xf2, 0x92,
0x65, 0x1c, 0x22, 0x88,
0xbb, 0xff, 0x46, 0x09,
0x0b, 0xde, 0x5e, 0x71,
0x77, 0x87, 0xeb, 0x84,
0xa9, 0x54, 0xc2, 0x45,
0xe9, 0x4e, 0x29, 0xb3
]);
deepEqual('0123456789abcdef01234',
stringFromBytes(videojs.Hls.decrypt(encrypted, key, initVector)),
'decrypted multiple blocks');
});
})(window, window.videojs);
......@@ -77,6 +77,7 @@ module.exports = function(config) {
'../node_modules/sinon/lib/sinon/util/fake_timers.js',
'../node_modules/video.js/dist/video-js/video.js',
'../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js',
'../node_modules/pkcs7/dist/pkcs7.unpad.js',
'../test/karma-qunit-shim.js',
'../src/videojs-hls.js',
'../src/xhr.js',
......@@ -88,6 +89,7 @@ module.exports = function(config) {
'../src/stream.js',
'../src/m3u8/m3u8-parser.js',
'../src/playlist-loader.js',
'../src/decrypter.js',
'../tmp/manifests.js',
'../tmp/expected.js',
'tsSegment-bc.js',
......
......@@ -41,6 +41,7 @@ module.exports = function(config) {
'../node_modules/sinon/lib/sinon/util/fake_timers.js',
'../node_modules/video.js/dist/video-js/video.js',
'../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js',
'../node_modules/pkcs7/dist/pkcs7.unpad.js',
'../test/karma-qunit-shim.js',
'../src/videojs-hls.js',
'../src/xhr.js',
......@@ -52,6 +53,7 @@ module.exports = function(config) {
'../src/stream.js',
'../src/m3u8/m3u8-parser.js',
'../src/playlist-loader.js',
'../src/decrypter.js',
'../tmp/manifests.js',
'../tmp/expected.js',
'tsSegment-bc.js',
......
......@@ -512,6 +512,94 @@
strictEqual(element.tagType, 'endlist', 'the tag type is stream-inf');
});
// #EXT-X-KEY
test('parses valid #EXT-X-KEY tags', function() {
var
manifest = '#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
deepEqual(element, {
type: 'tag',
tagType: 'key',
attributes: {
METHOD: 'AES-128',
URI: 'https://priv.example.com/key.php?r=52'
}
}, 'parsed a valid key');
manifest = '#EXT-X-KEY:URI="https://example.com/key#1",METHOD=FutureType-1024\n';
lineStream.push(manifest);
ok(element, 'an event was triggered');
deepEqual(element, {
type: 'tag',
tagType: 'key',
attributes: {
METHOD: 'FutureType-1024',
URI: 'https://example.com/key#1'
}
}, 'parsed the attribute list independent of order');
manifest = '#EXT-X-KEY:IV=1234567890abcdef1234567890abcdef\n';
lineStream.push(manifest);
ok(element.attributes.IV, 'detected an IV attribute');
deepEqual(element.attributes.IV, new Uint32Array([
0x12345678,
0x90abcdef,
0x12345678,
0x90abcdef
]), 'parsed an IV value');
});
test('parses minimal #EXT-X-KEY tags', function() {
var
manifest = '#EXT-X-KEY:\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
ok(element, 'an event was triggered');
deepEqual(element, {
type: 'tag',
tagType: 'key'
}, 'parsed a minimal key tag');
});
test('parses lightly-broken #EXT-X-KEY tags', function() {
var
manifest = '#EXT-X-KEY:URI=\'https://example.com/single-quote\',METHOD=AES-128\n',
element;
parseStream.on('data', function(elem) {
element = elem;
});
lineStream.push(manifest);
strictEqual(element.attributes.URI,
'https://example.com/single-quote',
'parsed a single-quoted uri');
element = null;
manifest = '#EXT-X-KEYURI="https://example.com/key",METHOD=AES-128\n';
lineStream.push(manifest);
strictEqual(element.tagType, 'key', 'parsed the tag type');
strictEqual(element.attributes.URI,
'https://example.com/key',
'inferred a colon after the tag type');
element = null;
manifest = '#EXT-X-KEY: URI = "https://example.com/key",METHOD=AES-128\n';
lineStream.push(manifest);
strictEqual(element.attributes.URI,
'https://example.com/key',
'trims and removes quotes around the URI');
});
test('ignores empty lines', function() {
var
manifest = '\n',
......
{
"allowCache": true,
"mediaSequence": 7794,
"segments": [
{
"duration": 2.833,
"key": {
"method": "AES-128",
"uri": "https://priv.example.com/key.php?r=52"
},
"uri": "http://media.example.com/fileSequence52-A.ts"
},
{
"duration": 15,
"key": {
"method": "AES-128",
"uri": "https://priv.example.com/key.php?r=52"
},
"uri": "http://media.example.com/fileSequence52-B.ts"
},
{
"duration": 13.333,
"key": {
"method": "AES-128",
"uri": "https://priv.example.com/key.php?r=52"
},
"uri": "http://media.example.com/fileSequence52-C.ts"
},
{
"duration": 15,
"key": {
"method": "AES-128",
"uri": "https://priv.example.com/key.php?r=53"
},
"uri": "http://media.example.com/fileSequence53-A.ts"
},
{
"duration": 15,
"uri": "http://media.example.com/fileSequence53-B.ts"
}
],
"targetDuration": 15
}
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:7794
#EXT-X-TARGETDURATION:15
#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"
#EXTINF:2.833,
http://media.example.com/fileSequence52-A.ts
#EXTINF:15.0,
http://media.example.com/fileSequence52-B.ts
#EXTINF:13.333,
http://media.example.com/fileSequence52-C.ts
#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=53"
#EXTINF:15.0,
http://media.example.com/fileSequence53-A.ts
#EXT-X-KEY:METHOD=NONE
#EXTINF:15.0,
http://media.example.com/fileSequence53-B.ts
\ No newline at end of file
......@@ -43,10 +43,20 @@
<section>
<h2>Inputs</h2>
<form id="inputs">
<label>
Your original MP2T segment:
<input type="file" id="original">
</label>
<fieldset>
<label>
Your original MP2T segment:
<input type="file" id="original">
</label>
<label>
Key (optional):
<input type="text" id="key">
</label>
<label>
IV (optional):
<input type="text" id="iv">
</label>
</fieldset>
<label>
A working, FLV version of the underlying stream
produced by another tool:
......@@ -105,11 +115,15 @@
<script src="../../src/h264-stream.js"></script>
<script src="../../src/aac-stream.js"></script>
<script src="../../src/segment-parser.js"></script>
<script src="../../node_modules/pkcs7/dist/pkcs7.unpad.js"></script>
<script src="../../src/decrypter.js"></script>
<script src="../../src/bin-utils.js"></script>
<script>
var inputs = document.getElementById('inputs'),
original = document.getElementById('original'),
key = document.getElementById('key'),
iv = document.getElementById('iv'),
working = document.getElementById('working'),
vjsTags = document.querySelector('.vjs-tags'),
......@@ -132,6 +146,7 @@
var parser = new videojs.Hls.SegmentParser(),
tags = [parser.getFlvHeader()],
tag,
bytes,
hex,
li,
byteLength = 0,
......@@ -142,7 +157,20 @@
// clear old tag info
vjsTags.innerHTML = '';
parser.parseSegmentBinaryData(new Uint8Array(reader.result));
// optionally, decrypt the segment
if (key.value && iv.value) {
bytes = videojs.Hls.decrypt(new Uint8Array(reader.result),
key.value.match(/([0-9a-f]{8})/gi).map(function(e) {
return parseInt(e, 16);
}),
iv.value.match(/([0-9a-f]{8})/gi).map(function(e) {
return parseInt(e, 16);
}));
} else {
bytes = new Uint8Array(reader.result);
}
parser.parseSegmentBinaryData(bytes);
// collect all the tags
while (parser.tagsAvailable()) {
......
......@@ -31,6 +31,8 @@
<script src="../src/stream.js"></script>
<script src="../src/m3u8/m3u8-parser.js"></script>
<script src="../src/playlist-loader.js"></script>
<script src="../node_modules/pkcs7/dist/pkcs7.unpad.js"></script>
<script src="../src/decrypter.js"></script>
<!-- M3U8 TEST DATA -->
<script src="../tmp/manifests.js"></script>
<script src="../tmp/expected.js"></script>
......@@ -55,6 +57,7 @@
<script src="flv-tag_test.js"></script>
<script src="m3u8_test.js"></script>
<script src="playlist-loader_test.js"></script>
<script src="decrypter_test.js"></script>
</head>
<body>
<div id="qunit"></div>
......