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) { ...@@ -74,6 +74,7 @@ module.exports = function(grunt) {
74 connect: { 74 connect: {
75 dev: { 75 dev: {
76 options: { 76 options: {
77 hostname: '*',
77 port: 9999, 78 port: 9999,
78 keepalive: true 79 keepalive: true
79 } 80 }
......
...@@ -10,4 +10,40 @@ Unless required by applicable law or agreed to in writing, software ...@@ -10,4 +10,40 @@ Unless required by applicable law or agreed to in writing, software
10 distributed under the License is distributed on an "AS IS" BASIS, 10 distributed under the License is distributed on an "AS IS" BASIS,
11 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 See the License for the specific language governing permissions and 12 See the License for the specific language governing permissions and
13 limitations under the License.
...\ No newline at end of file ...\ No newline at end of file
13 limitations under the License.
14
15 The AES decryption implementation in this project is derived from the
16 Stanford Javascript Cryptography Library
17 (http://bitwiseshiftleft.github.io/sjcl/). That work is covered by the
18 following copyright and permission notice:
19
20 Copyright 2009-2010 Emily Stark, Mike Hamburg, Dan Boneh.
21 All rights reserved.
22
23 Redistribution and use in source and binary forms, with or without
24 modification, are permitted provided that the following conditions are
25 met:
26
27 1. Redistributions of source code must retain the above copyright
28 notice, this list of conditions and the following disclaimer.
29
30 2. Redistributions in binary form must reproduce the above
31 copyright notice, this list of conditions and the following
32 disclaimer in the documentation and/or other materials provided
33 with the distribution.
34
35 THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
36 IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
37 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
38 DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR CONTRIBUTORS BE
39 LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
40 CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
41 SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
42 BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
43 WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
44 OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
45 IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
46
47 The views and conclusions contained in the software and documentation
48 are those of the authors and should not be interpreted as representing
49 official policies, either expressed or implied, of the authors.
...\ No newline at end of file ...\ No newline at end of file
......
1 # Encrypted HTTP Live Streaming
2 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:
3
4 ```sh
5 # encrypt the text "hello" into a file
6 # since this is for testing, skip the key salting so the output is stable
7 # using -nosalt outside of testing is a terrible idea!
8 echo -n "hello" | pkcs7 | \
9 openssl enc -aes-128-cbc -nopad -nosalt -K $KEY -iv $IV > hello.encrypted
10
11 # xxd is a handy way of translating binary into a format easily consumed by
12 # javascript
13 xxd -i hello.encrypted
14 ```
15
16 Later, you can decrypt it:
17
18 ```sh
19 openssl enc -d -nopad -aes-128-cbc -K $KEY -iv $IV
20 ```
...@@ -27,6 +27,11 @@ ...@@ -27,6 +27,11 @@
27 <script src="src/stream.js"></script> 27 <script src="src/stream.js"></script>
28 <script src="src/m3u8/m3u8-parser.js"></script> 28 <script src="src/m3u8/m3u8-parser.js"></script>
29 <script src="src/playlist-loader.js"></script> 29 <script src="src/playlist-loader.js"></script>
30
31 <script src="node_modules/pkcs7/dist/pkcs7.unpad.js"></script>
32 <script src="src/decrypter.js"></script>
33
34 <script src="src/bin-utils.js"></script>
30 35
31 <!-- example MPEG2-TS segments --> 36 <!-- example MPEG2-TS segments -->
32 <!-- bipbop --> 37 <!-- bipbop -->
...@@ -37,12 +42,14 @@ ...@@ -37,12 +42,14 @@
37 <style> 42 <style>
38 body { 43 body {
39 font-family: Arial, sans-serif; 44 font-family: Arial, sans-serif;
45 margin: 20px;
40 } 46 }
41 .info { 47 .info {
42 background-color: #eee; 48 background-color: #eee;
43 border: thin solid #333; 49 border: thin solid #333;
44 border-radius: 3px; 50 border-radius: 3px;
45 padding: 0 5px; 51 padding: 0 5px;
52 margin: 20px 0;
46 } 53 }
47 </style> 54 </style>
48 55
......
...@@ -37,6 +37,7 @@ ...@@ -37,6 +37,7 @@
37 "video.js": "^4.7.2" 37 "video.js": "^4.7.2"
38 }, 38 },
39 "dependencies": { 39 "dependencies": {
40 "pkcs7": "^0.2.2",
40 "videojs-contrib-media-sources": "^0.3.0" 41 "videojs-contrib-media-sources": "^0.3.0"
41 } 42 }
42 } 43 }
......
...@@ -4,12 +4,12 @@ ...@@ -4,12 +4,12 @@
4 var 4 var
5 bytes = Array.prototype.slice.call(data), 5 bytes = Array.prototype.slice.call(data),
6 step = 16, 6 step = 16,
7 formatHexString = function(e) { 7 formatHexString = function(e, i) {
8 var value = e.toString(16); 8 var value = e.toString(16);
9 return "00".substring(0, 2 - value.length) + value; 9 return "00".substring(0, 2 - value.length) + value + (i % 2 ? ' ' : '');
10 }, 10 },
11 formatAsciiString = function(e) { 11 formatAsciiString = function(e) {
12 if (e > 32 && e < 125) { 12 if (e >= 0x20 && e < 0x7e) {
13 return String.fromCharCode(e); 13 return String.fromCharCode(e);
14 } 14 }
15 return '.'; 15 return '.';
...@@ -18,9 +18,9 @@ ...@@ -18,9 +18,9 @@
18 hex, 18 hex,
19 ascii; 19 ascii;
20 for (var j = 0; j < bytes.length / step; j++) { 20 for (var j = 0; j < bytes.length / step; j++) {
21 hex = bytes.slice(j * step, j * step + step).map(formatHexString).join(' '); 21 hex = bytes.slice(j * step, j * step + step).map(formatHexString).join('');
22 ascii = bytes.slice(j * step, j * step + step).map(formatAsciiString).join(''); 22 ascii = bytes.slice(j * step, j * step + step).map(formatAsciiString).join('');
23 result += hex + ' ' + ascii + '\n'; 23 result += hex + ' ' + ascii + '\n';
24 } 24 }
25 return result; 25 return result;
26 }, 26 },
......
1 /*
2 * videojs-hls
3 *
4 * Copyright (c) 2014 Brightcove
5 * All rights reserved.
6 *
7 * This file contains an adaptation of the AES decryption algorithm
8 * from the Standford Javascript Cryptography Library. That work is
9 * covered by the following copyright and permissions notice:
10 *
11 * Copyright 2009-2010 Emily Stark, Mike Hamburg, Dan Boneh.
12 * All rights reserved.
13 *
14 * Redistribution and use in source and binary forms, with or without
15 * modification, are permitted provided that the following conditions are
16 * met:
17 *
18 * 1. Redistributions of source code must retain the above copyright
19 * notice, this list of conditions and the following disclaimer.
20 *
21 * 2. Redistributions in binary form must reproduce the above
22 * copyright notice, this list of conditions and the following
23 * disclaimer in the documentation and/or other materials provided
24 * with the distribution.
25 *
26 * THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
27 * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
28 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
29 * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR CONTRIBUTORS BE
30 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
31 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
32 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
33 * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
34 * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
35 * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
36 * IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
37 *
38 * The views and conclusions contained in the software and documentation
39 * are those of the authors and should not be interpreted as representing
40 * official policies, either expressed or implied, of the authors.
41 */
42 (function(window, videojs, unpad) {
43 'use strict';
44
45 var AES, decrypt;
46
47 /**
48 * Schedule out an AES key for both encryption and decryption. This
49 * is a low-level class. Use a cipher mode to do bulk encryption.
50 *
51 * @constructor
52 * @param key {Array} The key as an array of 4, 6 or 8 words.
53 */
54 AES = function (key) {
55 this._precompute();
56
57 var i, j, tmp,
58 encKey, decKey,
59 sbox = this._tables[0][4], decTable = this._tables[1],
60 keyLen = key.length, rcon = 1;
61
62 if (keyLen !== 4 && keyLen !== 6 && keyLen !== 8) {
63 throw new Error("Invalid aes key size");
64 }
65
66 encKey = key.slice(0);
67 decKey = [];
68 this._key = [encKey, decKey];
69
70 // schedule encryption keys
71 for (i = keyLen; i < 4 * keyLen + 28; i++) {
72 tmp = encKey[i-1];
73
74 // apply sbox
75 if (i%keyLen === 0 || (keyLen === 8 && i%keyLen === 4)) {
76 tmp = sbox[tmp>>>24]<<24 ^ sbox[tmp>>16&255]<<16 ^ sbox[tmp>>8&255]<<8 ^ sbox[tmp&255];
77
78 // shift rows and add rcon
79 if (i%keyLen === 0) {
80 tmp = tmp<<8 ^ tmp>>>24 ^ rcon<<24;
81 rcon = rcon<<1 ^ (rcon>>7)*283;
82 }
83 }
84
85 encKey[i] = encKey[i-keyLen] ^ tmp;
86 }
87
88 // schedule decryption keys
89 for (j = 0; i; j++, i--) {
90 tmp = encKey[j&3 ? i : i - 4];
91 if (i<=4 || j<4) {
92 decKey[j] = tmp;
93 } else {
94 decKey[j] = decTable[0][sbox[tmp>>>24 ]] ^
95 decTable[1][sbox[tmp>>16 & 255]] ^
96 decTable[2][sbox[tmp>>8 & 255]] ^
97 decTable[3][sbox[tmp & 255]];
98 }
99 }
100 };
101
102 AES.prototype = {
103 /**
104 * The expanded S-box and inverse S-box tables. These will be computed
105 * on the client so that we don't have to send them down the wire.
106 *
107 * There are two tables, _tables[0] is for encryption and
108 * _tables[1] is for decryption.
109 *
110 * The first 4 sub-tables are the expanded S-box with MixColumns. The
111 * last (_tables[01][4]) is the S-box itself.
112 *
113 * @private
114 */
115 _tables: [[[],[],[],[],[]],[[],[],[],[],[]]],
116
117 /**
118 * Expand the S-box tables.
119 *
120 * @private
121 */
122 _precompute: function () {
123 var encTable = this._tables[0], decTable = this._tables[1],
124 sbox = encTable[4], sboxInv = decTable[4],
125 i, x, xInv, d=[], th=[], x2, x4, x8, s, tEnc, tDec;
126
127 // Compute double and third tables
128 for (i = 0; i < 256; i++) {
129 th[( d[i] = i<<1 ^ (i>>7)*283 )^i]=i;
130 }
131
132 for (x = xInv = 0; !sbox[x]; x ^= x2 || 1, xInv = th[xInv] || 1) {
133 // Compute sbox
134 s = xInv ^ xInv<<1 ^ xInv<<2 ^ xInv<<3 ^ xInv<<4;
135 s = s>>8 ^ s&255 ^ 99;
136 sbox[x] = s;
137 sboxInv[s] = x;
138
139 // Compute MixColumns
140 x8 = d[x4 = d[x2 = d[x]]];
141 tDec = x8*0x1010101 ^ x4*0x10001 ^ x2*0x101 ^ x*0x1010100;
142 tEnc = d[s]*0x101 ^ s*0x1010100;
143
144 for (i = 0; i < 4; i++) {
145 encTable[i][x] = tEnc = tEnc<<24 ^ tEnc>>>8;
146 decTable[i][s] = tDec = tDec<<24 ^ tDec>>>8;
147 }
148 }
149
150 // Compactify. Considerable speedup on Firefox.
151 for (i = 0; i < 5; i++) {
152 encTable[i] = encTable[i].slice(0);
153 decTable[i] = decTable[i].slice(0);
154 }
155 },
156
157 /**
158 * Decrypt an array of 4 big-endian words.
159 * @param {Array} data The ciphertext.
160 * @return {Array} The plaintext.
161 */
162 decrypt:function (input) {
163 if (input.length !== 4) {
164 throw new Error("Invalid aes block size");
165 }
166
167 var key = this._key[1],
168 // state variables a,b,c,d are loaded with pre-whitened data
169 a = input[0] ^ key[0],
170 b = input[3] ^ key[1],
171 c = input[2] ^ key[2],
172 d = input[1] ^ key[3],
173 a2, b2, c2,
174
175 nInnerRounds = key.length/4 - 2,
176 i,
177 kIndex = 4,
178 out = [0,0,0,0],
179 table = this._tables[1],
180
181 // load up the tables
182 t0 = table[0],
183 t1 = table[1],
184 t2 = table[2],
185 t3 = table[3],
186 sbox = table[4];
187
188 // Inner rounds. Cribbed from OpenSSL.
189 for (i = 0; i < nInnerRounds; i++) {
190 a2 = t0[a>>>24] ^ t1[b>>16 & 255] ^ t2[c>>8 & 255] ^ t3[d & 255] ^ key[kIndex];
191 b2 = t0[b>>>24] ^ t1[c>>16 & 255] ^ t2[d>>8 & 255] ^ t3[a & 255] ^ key[kIndex + 1];
192 c2 = t0[c>>>24] ^ t1[d>>16 & 255] ^ t2[a>>8 & 255] ^ t3[b & 255] ^ key[kIndex + 2];
193 d = t0[d>>>24] ^ t1[a>>16 & 255] ^ t2[b>>8 & 255] ^ t3[c & 255] ^ key[kIndex + 3];
194 kIndex += 4;
195 a=a2; b=b2; c=c2;
196 }
197
198 // Last round.
199 for (i = 0; i < 4; i++) {
200 out[3 & -i] =
201 sbox[a>>>24 ]<<24 ^
202 sbox[b>>16 & 255]<<16 ^
203 sbox[c>>8 & 255]<<8 ^
204 sbox[d & 255] ^
205 key[kIndex++];
206 a2=a; a=b; b=c; c=d; d=a2;
207 }
208
209 return out;
210 }
211 };
212
213 decrypt = function(encrypted, key, initVector) {
214 var
215 encryptedView = new DataView(encrypted.buffer),
216 platformEndian = new Uint32Array(encrypted.byteLength / 4),
217 decipher = new AES(Array.prototype.slice.call(key)),
218 decrypted = new Uint8Array(encrypted.byteLength),
219 decryptedView = new DataView(decrypted.buffer),
220 decryptedBlock,
221 word,
222 byte;
223
224 // convert big-endian input to platform byte order for decryption
225 for (byte = 0; byte < encrypted.byteLength; byte += 4) {
226 platformEndian[byte >>> 2] = encryptedView.getUint32(byte);
227 }
228 // decrypt four word sequences, applying cipher-block chaining (CBC)
229 // to each decrypted block
230 for (word = 0; word < platformEndian.length; word += 4) {
231 // decrypt the block
232 decryptedBlock = decipher.decrypt(platformEndian.subarray(word, word + 4));
233
234 // XOR with the IV, and restore network byte-order to obtain the
235 // plaintext
236 byte = word << 2;
237 decryptedView.setUint32(byte, decryptedBlock[0] ^ initVector[0]);
238 decryptedView.setUint32(byte + 4, decryptedBlock[1] ^ initVector[1]);
239 decryptedView.setUint32(byte + 8, decryptedBlock[2] ^ initVector[2]);
240 decryptedView.setUint32(byte + 12, decryptedBlock[3] ^ initVector[3]);
241
242 // setup the IV for the next round
243 initVector = platformEndian.subarray(word, word + 4);
244 }
245
246 // remove any padding
247 return unpad(decrypted);
248 };
249
250 // exports
251 videojs.Hls.decrypt = decrypt;
252
253 })(window, window.videojs, window.pkcs7.unpad);
1 /** 1 /**
2 * Utilities for parsing M3U8 files. If the entire manifest is available, 2 * Utilities for parsing M3U8 files. If the entire manifest is available,
3 * `Parser` will create a object representation with enough detail for managing 3 * `Parser` will create an object representation with enough detail for managing
4 * playback. `ParseStream` and `LineStream` are lower-level parsing primitives 4 * playback. `ParseStream` and `LineStream` are lower-level parsing primitives
5 * that do not assume the entirety of the manifest is ready and expose a 5 * that do not assume the entirety of the manifest is ready and expose a
6 * ReadableStream-like interface. 6 * ReadableStream-like interface.
...@@ -8,27 +8,41 @@ ...@@ -8,27 +8,41 @@
8 (function(videojs, parseInt, isFinite, mergeOptions, undefined) { 8 (function(videojs, parseInt, isFinite, mergeOptions, undefined) {
9 var 9 var
10 noop = function() {}, 10 noop = function() {},
11
12 // "forgiving" attribute list psuedo-grammar:
13 // attributes -> keyvalue (',' keyvalue)*
14 // keyvalue -> key '=' value
15 // key -> [^=]*
16 // value -> '"' [^"]* '"' | [^,]*
17 attributeSeparator = (function() {
18 var
19 key = '[^=]*',
20 value = '"[^"]*"|[^,]*',
21 keyvalue = '(?:' + key + ')=(?:' + value + ')';
22
23 return new RegExp('(?:^|,)(' + keyvalue + ')');
24 })(),
11 parseAttributes = function(attributes) { 25 parseAttributes = function(attributes) {
12 var 26 var
13 attrs = attributes.split(','), 27 // split the string using attributes as the separator
28 attrs = attributes.split(attributeSeparator),
14 i = attrs.length, 29 i = attrs.length,
15 result = {}, 30 result = {},
16 attr; 31 attr;
17 32
18 while (i--) { 33 while (i--) {
19 attr = attrs[i].split('='); 34 // filter out unmatched portions of the string
20 attr[0] = attr[0].replace(/^\s+|\s+$/g, ''); 35 if (attrs[i] === '') {
21 36 continue;
22 // This is not sexy, but gives us the resulting object we want.
23 if (attr[1]) {
24 attr[1] = attr[1].replace(/^\s+|\s+$/g, '');
25 if (attr[1].indexOf('"') !== -1) {
26 attr[1] = attr[1].split('"')[1];
27 }
28 result[attr[0]] = attr[1];
29 } else {
30 attrs[i - 1] = attrs[i - 1] + ',' + attr[0];
31 } 37 }
38
39 // split the key and value
40 attr = /([^=]*)=(.*)/.exec(attrs[i]).slice(1);
41 // trim whitespace and remove optional quotes around the value
42 attr[0] = attr[0].replace(/^\s+|\s+$/g, '');
43 attr[1] = attr[1].replace(/^\s+|\s+$/g, '');
44 attr[1] = attr[1].replace(/^['"](.*)['"]$/g, '$1');
45 result[attr[0]] = attr[1];
32 } 46 }
33 return result; 47 return result;
34 }, 48 },
...@@ -281,6 +295,27 @@ ...@@ -281,6 +295,27 @@
281 }); 295 });
282 return; 296 return;
283 } 297 }
298 match = (/^#EXT-X-KEY:?(.*)$/).exec(line);
299 if (match) {
300 event = {
301 type: 'tag',
302 tagType: 'key'
303 };
304 if (match[1]) {
305 event.attributes = parseAttributes(match[1]);
306 // parse the IV string into a Uint32Array
307 if (event.attributes.IV) {
308 event.attributes.IV = event.attributes.IV.match(/.{8}/g);
309 event.attributes.IV[0] = parseInt(event.attributes.IV[0], 16);
310 event.attributes.IV[1] = parseInt(event.attributes.IV[1], 16);
311 event.attributes.IV[2] = parseInt(event.attributes.IV[2], 16);
312 event.attributes.IV[3] = parseInt(event.attributes.IV[3], 16);
313 event.attributes.IV = new Uint32Array(event.attributes.IV);
314 }
315 }
316 this.trigger('data', event);
317 return;
318 }
284 319
285 // unknown tag type 320 // unknown tag type
286 this.trigger('data', { 321 this.trigger('data', {
...@@ -311,7 +346,8 @@ ...@@ -311,7 +346,8 @@
311 var 346 var
312 self = this, 347 self = this,
313 uris = [], 348 uris = [],
314 currentUri = {}; 349 currentUri = {},
350 key;
315 Parser.prototype.init.call(this); 351 Parser.prototype.init.call(this);
316 352
317 this.lineStream = new LineStream(); 353 this.lineStream = new LineStream();
...@@ -373,6 +409,36 @@ ...@@ -373,6 +409,36 @@
373 this.manifest.segments = uris; 409 this.manifest.segments = uris;
374 410
375 }, 411 },
412 'key': function() {
413 if (!entry.attributes) {
414 this.trigger('warn', {
415 message: 'ignoring key declaration without attribute list'
416 });
417 return;
418 }
419 // clear the active encryption key
420 if (entry.attributes.METHOD === 'NONE') {
421 key = null;
422 return;
423 }
424 if (!entry.attributes.URI) {
425 this.trigger('warn', {
426 message: 'ignoring key declaration without URI'
427 });
428 return;
429 }
430 if (!entry.attributes.METHOD) {
431 this.trigger('warn', {
432 message: 'defaulting key method to AES-128'
433 });
434 }
435
436 // setup an encryption key for upcoming segments
437 key = {
438 method: entry.attributes.METHOD || 'AES-128',
439 uri: entry.attributes.URI
440 };
441 },
376 'media-sequence': function() { 442 'media-sequence': function() {
377 if (!isFinite(entry.number)) { 443 if (!isFinite(entry.number)) {
378 this.trigger('warn', { 444 this.trigger('warn', {
...@@ -442,6 +508,10 @@ ...@@ -442,6 +508,10 @@
442 }); 508 });
443 currentUri.duration = this.manifest.targetDuration; 509 currentUri.duration = this.manifest.targetDuration;
444 } 510 }
511 // annotate with encryption information, if necessary
512 if (key) {
513 currentUri.key = key;
514 }
445 515
446 // prepare for the next URI 516 // prepare for the next URI
447 currentUri = {}; 517 currentUri = {};
......
...@@ -12,8 +12,15 @@ var ...@@ -12,8 +12,15 @@ var
12 // a fudge factor to apply to advertised playlist bitrates to account for 12 // a fudge factor to apply to advertised playlist bitrates to account for
13 // temporary flucations in client bandwidth 13 // temporary flucations in client bandwidth
14 bandwidthVariance = 1.1, 14 bandwidthVariance = 1.1,
15 keyXhr,
16 keyFailed,
15 resolveUrl; 17 resolveUrl;
16 18
19 // returns true if a key has failed to download within a certain amount of retries
20 keyFailed = function(key) {
21 return key.retries && key.retries >= 2;
22 };
23
17 videojs.Hls = videojs.Flash.extend({ 24 videojs.Hls = videojs.Flash.extend({
18 init: function(player, options, ready) { 25 init: function(player, options, ready) {
19 var 26 var
...@@ -116,11 +123,20 @@ videojs.Hls.prototype.handleSourceOpen = function() { ...@@ -116,11 +123,20 @@ videojs.Hls.prototype.handleSourceOpen = function() {
116 this.updateDuration(this.playlists.media()); 123 this.updateDuration(this.playlists.media());
117 this.mediaIndex = videojs.Hls.translateMediaIndex(this.mediaIndex, oldMediaPlaylist, updatedPlaylist); 124 this.mediaIndex = videojs.Hls.translateMediaIndex(this.mediaIndex, oldMediaPlaylist, updatedPlaylist);
118 oldMediaPlaylist = updatedPlaylist; 125 oldMediaPlaylist = updatedPlaylist;
126
127 this.fetchKeys(updatedPlaylist, this.mediaIndex);
119 })); 128 }));
120 129
121 this.playlists.on('mediachange', function() { 130 this.playlists.on('mediachange', videojs.bind(this, function() {
131 // abort outstanding key requests and check if new keys need to be retrieved
132 if (keyXhr) {
133 keyXhr.abort();
134 keyXhr = null;
135 this.fetchKeys(this.playlists.media(), this.mediaIndex);
136 }
137
122 player.trigger('mediachange'); 138 player.trigger('mediachange');
123 }); 139 }));
124 140
125 // if autoplay is enabled, begin playback. This is duplicative of 141 // if autoplay is enabled, begin playback. This is duplicative of
126 // code in video.js but is required because play() must be invoked 142 // code in video.js but is required because play() must be invoked
...@@ -175,6 +191,14 @@ videojs.Hls.prototype.setCurrentTime = function(currentTime) { ...@@ -175,6 +191,14 @@ videojs.Hls.prototype.setCurrentTime = function(currentTime) {
175 this.segmentXhr_.abort(); 191 this.segmentXhr_.abort();
176 } 192 }
177 193
194 // fetch new encryption keys, if necessary
195 if (keyXhr) {
196 keyXhr.aborted = true;
197 keyXhr.abort();
198 keyXhr = null;
199 this.fetchKeys(this.playlists.media(), this.mediaIndex);
200 }
201
178 // clear out any buffered segments 202 // clear out any buffered segments
179 this.segmentBuffer_ = []; 203 this.segmentBuffer_ = [];
180 204
...@@ -212,6 +236,11 @@ videojs.Hls.prototype.dispose = function() { ...@@ -212,6 +236,11 @@ videojs.Hls.prototype.dispose = function() {
212 this.segmentXhr_.onreadystatechange = null; 236 this.segmentXhr_.onreadystatechange = null;
213 this.segmentXhr_.abort(); 237 this.segmentXhr_.abort();
214 } 238 }
239 if (keyXhr) {
240 keyXhr.onreadystatechange = null;
241 keyXhr.abort();
242 keyXhr = null;
243 }
215 if (this.playlists) { 244 if (this.playlists) {
216 this.playlists.dispose(); 245 this.playlists.dispose();
217 } 246 }
...@@ -357,8 +386,6 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, offset) { ...@@ -357,8 +386,6 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, offset) {
357 responseType: 'arraybuffer', 386 responseType: 'arraybuffer',
358 withCredentials: settings.withCredentials 387 withCredentials: settings.withCredentials
359 }, function(error, url) { 388 }, function(error, url) {
360 var tags;
361
362 // the segment request is no longer outstanding 389 // the segment request is no longer outstanding
363 tech.segmentXhr_ = null; 390 tech.segmentXhr_ = null;
364 391
...@@ -390,23 +417,15 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, offset) { ...@@ -390,23 +417,15 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, offset) {
390 tech.bandwidth = (this.response.byteLength / tech.segmentXhrTime) * 8 * 1000; 417 tech.bandwidth = (this.response.byteLength / tech.segmentXhrTime) * 8 * 1000;
391 tech.bytesReceived += this.response.byteLength; 418 tech.bytesReceived += this.response.byteLength;
392 419
393 // transmux the segment data from MP2T to FLV
394 tech.segmentParser_.parseSegmentBinaryData(new Uint8Array(this.response));
395 tech.segmentParser_.flushTags();
396
397 // package up all the work to append the segment 420 // package up all the work to append the segment
398 // if the segment is the start of a timestamp discontinuity, 421 // if the segment is the start of a timestamp discontinuity,
399 // we have to wait until the sourcebuffer is empty before 422 // we have to wait until the sourcebuffer is empty before
400 // aborting the source buffer processing 423 // aborting the source buffer processing
401 tags = [];
402 while (tech.segmentParser_.tagsAvailable()) {
403 tags.push(tech.segmentParser_.getNextTag());
404 }
405 tech.segmentBuffer_.push({ 424 tech.segmentBuffer_.push({
406 mediaIndex: tech.mediaIndex, 425 mediaIndex: tech.mediaIndex,
407 playlist: tech.playlists.media(), 426 playlist: tech.playlists.media(),
408 offset: offset, 427 offset: offset,
409 tags: tags 428 bytes: new Uint8Array(this.response)
410 }); 429 });
411 tech.drainBuffer(); 430 tech.drainBuffer();
412 431
...@@ -425,6 +444,7 @@ videojs.Hls.prototype.drainBuffer = function(event) { ...@@ -425,6 +444,7 @@ videojs.Hls.prototype.drainBuffer = function(event) {
425 playlist, 444 playlist,
426 offset, 445 offset,
427 tags, 446 tags,
447 bytes,
428 segment, 448 segment,
429 449
430 ptsTime, 450 ptsTime,
...@@ -438,9 +458,28 @@ videojs.Hls.prototype.drainBuffer = function(event) { ...@@ -438,9 +458,28 @@ videojs.Hls.prototype.drainBuffer = function(event) {
438 mediaIndex = segmentBuffer[0].mediaIndex; 458 mediaIndex = segmentBuffer[0].mediaIndex;
439 playlist = segmentBuffer[0].playlist; 459 playlist = segmentBuffer[0].playlist;
440 offset = segmentBuffer[0].offset; 460 offset = segmentBuffer[0].offset;
441 tags = segmentBuffer[0].tags; 461 bytes = segmentBuffer[0].bytes;
442 segment = playlist.segments[mediaIndex]; 462 segment = playlist.segments[mediaIndex];
443 463
464 if (segment.key) {
465 // this is an encrypted segment
466 // if the key download failed, we want to skip this segment
467 // but if the key hasn't downloaded yet, we want to try again later
468 if (keyFailed(segment.key)) {
469 return segmentBuffer.shift();
470 } else if (!segment.key.bytes) {
471 return;
472 } else {
473 // if the media sequence is greater than 2^32, the IV will be incorrect
474 // assuming 10s segments, that would be about 1300 years
475 bytes = videojs.Hls.decrypt(bytes,
476 segment.key.bytes,
477 new Uint32Array([
478 0, 0, 0,
479 mediaIndex + playlist.mediaSequence]));
480 }
481 }
482
444 event = event || {}; 483 event = event || {};
445 segmentOffset = videojs.Hls.getPlaylistDuration(playlist, 0, mediaIndex) * 1000; 484 segmentOffset = videojs.Hls.getPlaylistDuration(playlist, 0, mediaIndex) * 1000;
446 485
...@@ -456,6 +495,15 @@ videojs.Hls.prototype.drainBuffer = function(event) { ...@@ -456,6 +495,15 @@ videojs.Hls.prototype.drainBuffer = function(event) {
456 this.el().vjs_setProperty('currentTime', segmentOffset * 0.001); 495 this.el().vjs_setProperty('currentTime', segmentOffset * 0.001);
457 } 496 }
458 497
498 // transmux the segment data from MP2T to FLV
499 this.segmentParser_.parseSegmentBinaryData(bytes);
500 this.segmentParser_.flushTags();
501
502 tags = [];
503 while (this.segmentParser_.tagsAvailable()) {
504 tags.push(this.segmentParser_.getNextTag());
505 }
506
459 // if we're refilling the buffer after a seek, scan through the muxed 507 // if we're refilling the buffer after a seek, scan through the muxed
460 // FLV tags until we find the one that is closest to the desired 508 // FLV tags until we find the one that is closest to the desired
461 // playback time 509 // playback time
...@@ -492,6 +540,53 @@ videojs.Hls.prototype.drainBuffer = function(event) { ...@@ -492,6 +540,53 @@ videojs.Hls.prototype.drainBuffer = function(event) {
492 } 540 }
493 }; 541 };
494 542
543 videojs.Hls.prototype.fetchKeys = function(playlist, index) {
544 var i, key, tech, player, settings, view;
545
546 // if there is a pending XHR or no segments, don't do anything
547 if (keyXhr || !playlist.segments) {
548 return;
549 }
550
551 tech = this;
552 player = this.player();
553 settings = player.options().hls || {};
554
555 // jshint -W083
556 for (i = index; i < playlist.segments.length; i++) {
557 key = playlist.segments[i].key;
558 if (key && !key.bytes && !keyFailed(key)) {
559 keyXhr = videojs.Hls.xhr({
560 url: resolveUrl(playlist.uri, key.uri),
561 responseType: 'arraybuffer',
562 withCredentials: settings.withCredentials
563 }, function(err, url) {
564 keyXhr = null;
565
566 if (err || !this.response || this.response.byteLength !== 16) {
567 key.retries = key.retries || 0;
568 key.retries++;
569 if (!this.aborted) {
570 tech.fetchKeys(playlist, i);
571 }
572 return;
573 }
574
575 view = new DataView(this.response);
576 key.bytes = new Uint32Array([
577 view.getUint32(0),
578 view.getUint32(4),
579 view.getUint32(8),
580 view.getUint32(12)
581 ]);
582 tech.fetchKeys(playlist, i++, url);
583 });
584 break;
585 }
586 }
587 // jshint +W083
588 };
589
495 /** 590 /**
496 * Whether the browser has built-in HLS support. 591 * Whether the browser has built-in HLS support.
497 */ 592 */
......
1 (function(window, videojs, undefined) {
2 'use strict';
3 /*
4 ======== A Handy Little QUnit Reference ========
5 http://api.qunitjs.com/
6
7 Test methods:
8 module(name, {[setup][ ,teardown]})
9 test(name, callback)
10 expect(numberOfAssertions)
11 stop(increment)
12 start(decrement)
13 Test assertions:
14 ok(value, [message])
15 equal(actual, expected, [message])
16 notEqual(actual, expected, [message])
17 deepEqual(actual, expected, [message])
18 notDeepEqual(actual, expected, [message])
19 strictEqual(actual, expected, [message])
20 notStrictEqual(actual, expected, [message])
21 throws(block, [expected], [message])
22 */
23
24 // see docs/hlse.md for instructions on how test data was generated
25
26 var stringFromBytes = function(bytes) {
27 var result = '', i;
28
29 for (i = 0; i < bytes.length; i++) {
30 result += String.fromCharCode(bytes[i]);
31 }
32 return result;
33 };
34
35 module('Decryption');
36
37 test('decrypts a single AES-128 with PKCS7 block', function() {
38 var
39 key = new Uint32Array([0, 0, 0, 0]),
40 initVector = key,
41 // the string "howdy folks" encrypted
42 encrypted = new Uint8Array([
43 0xce, 0x90, 0x97, 0xd0,
44 0x08, 0x46, 0x4d, 0x18,
45 0x4f, 0xae, 0x01, 0x1c,
46 0x82, 0xa8, 0xf0, 0x67]);
47
48 deepEqual('howdy folks',
49 stringFromBytes(videojs.Hls.decrypt(encrypted, key, initVector)),
50 'decrypted with a byte array key');
51 });
52
53 test('decrypts multiple AES-128 blocks with CBC', function() {
54 var
55 key = new Uint32Array([0, 0, 0, 0]),
56 initVector = key,
57 // the string "0123456789abcdef01234" encrypted
58 encrypted = new Uint8Array([
59 0x14, 0xf5, 0xfe, 0x74,
60 0x69, 0x66, 0xf2, 0x92,
61 0x65, 0x1c, 0x22, 0x88,
62 0xbb, 0xff, 0x46, 0x09,
63
64 0x0b, 0xde, 0x5e, 0x71,
65 0x77, 0x87, 0xeb, 0x84,
66 0xa9, 0x54, 0xc2, 0x45,
67 0xe9, 0x4e, 0x29, 0xb3
68 ]);
69
70 deepEqual('0123456789abcdef01234',
71 stringFromBytes(videojs.Hls.decrypt(encrypted, key, initVector)),
72 'decrypted multiple blocks');
73 });
74
75 })(window, window.videojs);
...@@ -77,6 +77,7 @@ module.exports = function(config) { ...@@ -77,6 +77,7 @@ module.exports = function(config) {
77 '../node_modules/sinon/lib/sinon/util/fake_timers.js', 77 '../node_modules/sinon/lib/sinon/util/fake_timers.js',
78 '../node_modules/video.js/dist/video-js/video.js', 78 '../node_modules/video.js/dist/video-js/video.js',
79 '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js', 79 '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js',
80 '../node_modules/pkcs7/dist/pkcs7.unpad.js',
80 '../test/karma-qunit-shim.js', 81 '../test/karma-qunit-shim.js',
81 '../src/videojs-hls.js', 82 '../src/videojs-hls.js',
82 '../src/xhr.js', 83 '../src/xhr.js',
...@@ -88,6 +89,7 @@ module.exports = function(config) { ...@@ -88,6 +89,7 @@ module.exports = function(config) {
88 '../src/stream.js', 89 '../src/stream.js',
89 '../src/m3u8/m3u8-parser.js', 90 '../src/m3u8/m3u8-parser.js',
90 '../src/playlist-loader.js', 91 '../src/playlist-loader.js',
92 '../src/decrypter.js',
91 '../tmp/manifests.js', 93 '../tmp/manifests.js',
92 '../tmp/expected.js', 94 '../tmp/expected.js',
93 'tsSegment-bc.js', 95 'tsSegment-bc.js',
......
...@@ -41,6 +41,7 @@ module.exports = function(config) { ...@@ -41,6 +41,7 @@ module.exports = function(config) {
41 '../node_modules/sinon/lib/sinon/util/fake_timers.js', 41 '../node_modules/sinon/lib/sinon/util/fake_timers.js',
42 '../node_modules/video.js/dist/video-js/video.js', 42 '../node_modules/video.js/dist/video-js/video.js',
43 '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js', 43 '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js',
44 '../node_modules/pkcs7/dist/pkcs7.unpad.js',
44 '../test/karma-qunit-shim.js', 45 '../test/karma-qunit-shim.js',
45 '../src/videojs-hls.js', 46 '../src/videojs-hls.js',
46 '../src/xhr.js', 47 '../src/xhr.js',
...@@ -52,6 +53,7 @@ module.exports = function(config) { ...@@ -52,6 +53,7 @@ module.exports = function(config) {
52 '../src/stream.js', 53 '../src/stream.js',
53 '../src/m3u8/m3u8-parser.js', 54 '../src/m3u8/m3u8-parser.js',
54 '../src/playlist-loader.js', 55 '../src/playlist-loader.js',
56 '../src/decrypter.js',
55 '../tmp/manifests.js', 57 '../tmp/manifests.js',
56 '../tmp/expected.js', 58 '../tmp/expected.js',
57 'tsSegment-bc.js', 59 'tsSegment-bc.js',
......
...@@ -512,6 +512,94 @@ ...@@ -512,6 +512,94 @@
512 strictEqual(element.tagType, 'endlist', 'the tag type is stream-inf'); 512 strictEqual(element.tagType, 'endlist', 'the tag type is stream-inf');
513 }); 513 });
514 514
515 // #EXT-X-KEY
516 test('parses valid #EXT-X-KEY tags', function() {
517 var
518 manifest = '#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"\n',
519 element;
520 parseStream.on('data', function(elem) {
521 element = elem;
522 });
523 lineStream.push(manifest);
524
525 ok(element, 'an event was triggered');
526 deepEqual(element, {
527 type: 'tag',
528 tagType: 'key',
529 attributes: {
530 METHOD: 'AES-128',
531 URI: 'https://priv.example.com/key.php?r=52'
532 }
533 }, 'parsed a valid key');
534
535 manifest = '#EXT-X-KEY:URI="https://example.com/key#1",METHOD=FutureType-1024\n';
536 lineStream.push(manifest);
537 ok(element, 'an event was triggered');
538 deepEqual(element, {
539 type: 'tag',
540 tagType: 'key',
541 attributes: {
542 METHOD: 'FutureType-1024',
543 URI: 'https://example.com/key#1'
544 }
545 }, 'parsed the attribute list independent of order');
546
547 manifest = '#EXT-X-KEY:IV=1234567890abcdef1234567890abcdef\n';
548 lineStream.push(manifest);
549 ok(element.attributes.IV, 'detected an IV attribute');
550 deepEqual(element.attributes.IV, new Uint32Array([
551 0x12345678,
552 0x90abcdef,
553 0x12345678,
554 0x90abcdef
555 ]), 'parsed an IV value');
556 });
557
558 test('parses minimal #EXT-X-KEY tags', function() {
559 var
560 manifest = '#EXT-X-KEY:\n',
561 element;
562 parseStream.on('data', function(elem) {
563 element = elem;
564 });
565 lineStream.push(manifest);
566
567 ok(element, 'an event was triggered');
568 deepEqual(element, {
569 type: 'tag',
570 tagType: 'key'
571 }, 'parsed a minimal key tag');
572 });
573
574 test('parses lightly-broken #EXT-X-KEY tags', function() {
575 var
576 manifest = '#EXT-X-KEY:URI=\'https://example.com/single-quote\',METHOD=AES-128\n',
577 element;
578 parseStream.on('data', function(elem) {
579 element = elem;
580 });
581 lineStream.push(manifest);
582
583 strictEqual(element.attributes.URI,
584 'https://example.com/single-quote',
585 'parsed a single-quoted uri');
586
587 element = null;
588 manifest = '#EXT-X-KEYURI="https://example.com/key",METHOD=AES-128\n';
589 lineStream.push(manifest);
590 strictEqual(element.tagType, 'key', 'parsed the tag type');
591 strictEqual(element.attributes.URI,
592 'https://example.com/key',
593 'inferred a colon after the tag type');
594
595 element = null;
596 manifest = '#EXT-X-KEY: URI = "https://example.com/key",METHOD=AES-128\n';
597 lineStream.push(manifest);
598 strictEqual(element.attributes.URI,
599 'https://example.com/key',
600 'trims and removes quotes around the URI');
601 });
602
515 test('ignores empty lines', function() { 603 test('ignores empty lines', function() {
516 var 604 var
517 manifest = '\n', 605 manifest = '\n',
......
1 {
2 "allowCache": true,
3 "mediaSequence": 7794,
4 "segments": [
5 {
6 "duration": 2.833,
7 "key": {
8 "method": "AES-128",
9 "uri": "https://priv.example.com/key.php?r=52"
10 },
11 "uri": "http://media.example.com/fileSequence52-A.ts"
12 },
13 {
14 "duration": 15,
15 "key": {
16 "method": "AES-128",
17 "uri": "https://priv.example.com/key.php?r=52"
18 },
19 "uri": "http://media.example.com/fileSequence52-B.ts"
20 },
21 {
22 "duration": 13.333,
23 "key": {
24 "method": "AES-128",
25 "uri": "https://priv.example.com/key.php?r=52"
26 },
27 "uri": "http://media.example.com/fileSequence52-C.ts"
28 },
29 {
30 "duration": 15,
31 "key": {
32 "method": "AES-128",
33 "uri": "https://priv.example.com/key.php?r=53"
34 },
35 "uri": "http://media.example.com/fileSequence53-A.ts"
36 },
37 {
38 "duration": 15,
39 "uri": "http://media.example.com/fileSequence53-B.ts"
40 }
41 ],
42 "targetDuration": 15
43 }
1 #EXTM3U
2 #EXT-X-VERSION:3
3 #EXT-X-MEDIA-SEQUENCE:7794
4 #EXT-X-TARGETDURATION:15
5
6 #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"
7
8 #EXTINF:2.833,
9 http://media.example.com/fileSequence52-A.ts
10 #EXTINF:15.0,
11 http://media.example.com/fileSequence52-B.ts
12 #EXTINF:13.333,
13 http://media.example.com/fileSequence52-C.ts
14
15 #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=53"
16
17 #EXTINF:15.0,
18 http://media.example.com/fileSequence53-A.ts
19
20 #EXT-X-KEY:METHOD=NONE
21
22 #EXTINF:15.0,
23 http://media.example.com/fileSequence53-B.ts
...\ No newline at end of file ...\ No newline at end of file
...@@ -43,10 +43,20 @@ ...@@ -43,10 +43,20 @@
43 <section> 43 <section>
44 <h2>Inputs</h2> 44 <h2>Inputs</h2>
45 <form id="inputs"> 45 <form id="inputs">
46 <label> 46 <fieldset>
47 Your original MP2T segment: 47 <label>
48 <input type="file" id="original"> 48 Your original MP2T segment:
49 </label> 49 <input type="file" id="original">
50 </label>
51 <label>
52 Key (optional):
53 <input type="text" id="key">
54 </label>
55 <label>
56 IV (optional):
57 <input type="text" id="iv">
58 </label>
59 </fieldset>
50 <label> 60 <label>
51 A working, FLV version of the underlying stream 61 A working, FLV version of the underlying stream
52 produced by another tool: 62 produced by another tool:
...@@ -105,11 +115,15 @@ ...@@ -105,11 +115,15 @@
105 <script src="../../src/h264-stream.js"></script> 115 <script src="../../src/h264-stream.js"></script>
106 <script src="../../src/aac-stream.js"></script> 116 <script src="../../src/aac-stream.js"></script>
107 <script src="../../src/segment-parser.js"></script> 117 <script src="../../src/segment-parser.js"></script>
118 <script src="../../node_modules/pkcs7/dist/pkcs7.unpad.js"></script>
119 <script src="../../src/decrypter.js"></script>
108 120
109 <script src="../../src/bin-utils.js"></script> 121 <script src="../../src/bin-utils.js"></script>
110 <script> 122 <script>
111 var inputs = document.getElementById('inputs'), 123 var inputs = document.getElementById('inputs'),
112 original = document.getElementById('original'), 124 original = document.getElementById('original'),
125 key = document.getElementById('key'),
126 iv = document.getElementById('iv'),
113 working = document.getElementById('working'), 127 working = document.getElementById('working'),
114 128
115 vjsTags = document.querySelector('.vjs-tags'), 129 vjsTags = document.querySelector('.vjs-tags'),
...@@ -132,6 +146,7 @@ ...@@ -132,6 +146,7 @@
132 var parser = new videojs.Hls.SegmentParser(), 146 var parser = new videojs.Hls.SegmentParser(),
133 tags = [parser.getFlvHeader()], 147 tags = [parser.getFlvHeader()],
134 tag, 148 tag,
149 bytes,
135 hex, 150 hex,
136 li, 151 li,
137 byteLength = 0, 152 byteLength = 0,
...@@ -142,7 +157,20 @@ ...@@ -142,7 +157,20 @@
142 // clear old tag info 157 // clear old tag info
143 vjsTags.innerHTML = ''; 158 vjsTags.innerHTML = '';
144 159
145 parser.parseSegmentBinaryData(new Uint8Array(reader.result)); 160 // optionally, decrypt the segment
161 if (key.value && iv.value) {
162 bytes = videojs.Hls.decrypt(new Uint8Array(reader.result),
163 key.value.match(/([0-9a-f]{8})/gi).map(function(e) {
164 return parseInt(e, 16);
165 }),
166 iv.value.match(/([0-9a-f]{8})/gi).map(function(e) {
167 return parseInt(e, 16);
168 }));
169 } else {
170 bytes = new Uint8Array(reader.result);
171 }
172
173 parser.parseSegmentBinaryData(bytes);
146 174
147 // collect all the tags 175 // collect all the tags
148 while (parser.tagsAvailable()) { 176 while (parser.tagsAvailable()) {
......
...@@ -31,6 +31,8 @@ ...@@ -31,6 +31,8 @@
31 <script src="../src/stream.js"></script> 31 <script src="../src/stream.js"></script>
32 <script src="../src/m3u8/m3u8-parser.js"></script> 32 <script src="../src/m3u8/m3u8-parser.js"></script>
33 <script src="../src/playlist-loader.js"></script> 33 <script src="../src/playlist-loader.js"></script>
34 <script src="../node_modules/pkcs7/dist/pkcs7.unpad.js"></script>
35 <script src="../src/decrypter.js"></script>
34 <!-- M3U8 TEST DATA --> 36 <!-- M3U8 TEST DATA -->
35 <script src="../tmp/manifests.js"></script> 37 <script src="../tmp/manifests.js"></script>
36 <script src="../tmp/expected.js"></script> 38 <script src="../tmp/expected.js"></script>
...@@ -55,6 +57,7 @@ ...@@ -55,6 +57,7 @@
55 <script src="flv-tag_test.js"></script> 57 <script src="flv-tag_test.js"></script>
56 <script src="m3u8_test.js"></script> 58 <script src="m3u8_test.js"></script>
57 <script src="playlist-loader_test.js"></script> 59 <script src="playlist-loader_test.js"></script>
60 <script src="decrypter_test.js"></script>
58 </head> 61 </head>
59 <body> 62 <body>
60 <div id="qunit"></div> 63 <div id="qunit"></div>
......
...@@ -29,6 +29,7 @@ var ...@@ -29,6 +29,7 @@ var
29 oldSourceBuffer, 29 oldSourceBuffer,
30 oldFlashSupported, 30 oldFlashSupported,
31 oldNativeHlsSupport, 31 oldNativeHlsSupport,
32 oldDecrypt,
32 requests, 33 requests,
33 xhr, 34 xhr,
34 35
...@@ -135,6 +136,11 @@ module('HLS', { ...@@ -135,6 +136,11 @@ module('HLS', {
135 136
136 oldNativeHlsSupport = videojs.Hls.supportsNativeHls; 137 oldNativeHlsSupport = videojs.Hls.supportsNativeHls;
137 138
139 oldDecrypt = videojs.Hls.decrypt;
140 videojs.Hls.decrypt = function() {
141 return new Uint8Array([0]);
142 };
143
138 // fake XHRs 144 // fake XHRs
139 xhr = sinon.useFakeXMLHttpRequest(); 145 xhr = sinon.useFakeXMLHttpRequest();
140 requests = []; 146 requests = [];
...@@ -152,6 +158,7 @@ module('HLS', { ...@@ -152,6 +158,7 @@ module('HLS', {
152 videojs.MediaSource.open = oldMediaSourceOpen; 158 videojs.MediaSource.open = oldMediaSourceOpen;
153 videojs.Hls.SegmentParser = oldSegmentParser; 159 videojs.Hls.SegmentParser = oldSegmentParser;
154 videojs.Hls.supportsNativeHls = oldNativeHlsSupport; 160 videojs.Hls.supportsNativeHls = oldNativeHlsSupport;
161 videojs.Hls.decrypt = oldDecrypt;
155 videojs.SourceBuffer = oldSourceBuffer; 162 videojs.SourceBuffer = oldSourceBuffer;
156 window.setTimeout = oldSetTimeout; 163 window.setTimeout = oldSetTimeout;
157 xhr.restore(); 164 xhr.restore();
...@@ -1276,4 +1283,449 @@ test('calling play() at the end of a video resets the media index', function() { ...@@ -1276,4 +1283,449 @@ test('calling play() at the end of a video resets the media index', function() {
1276 strictEqual(player.hls.mediaIndex, 0, 'index is 1 after the first segment'); 1283 strictEqual(player.hls.mediaIndex, 0, 'index is 1 after the first segment');
1277 }); 1284 });
1278 1285
1286 test('calling fetchKeys() when a new playlist is loaded will create an XHR', function() {
1287 player.src({
1288 src: 'https://example.com/encrypted-media.m3u8',
1289 type: 'application/vnd.apple.mpegurl'
1290 });
1291 openMediaSource(player);
1292
1293 var oldMedia = player.hls.playlists.media;
1294 player.hls.playlists.media = function() {
1295 return {
1296 segments: [{
1297 key: {
1298 'method': 'AES-128',
1299 'uri': 'https://priv.example.com/key.php?r=52'
1300 },
1301 uri: 'http://media.example.com/fileSequence52-A.ts'
1302 }, {
1303 key: {
1304 'method': 'AES-128',
1305 'uri': 'https://priv.example.com/key.php?r=53'
1306 },
1307 uri: 'http://media.example.com/fileSequence53-B.ts'
1308 }]
1309 };
1310 };
1311
1312 player.hls.playlists.trigger('loadedplaylist');
1313 strictEqual(requests.length, 2, 'a key XHR is created');
1314 strictEqual(requests[1].url, player.hls.playlists.media().segments[0].key.uri, 'a key XHR is created with correct uri');
1315
1316 player.hls.playlists.media = oldMedia;
1317 });
1318
1319 test('a new keys XHR is created when a previous key XHR finishes', function() {
1320 player.src({
1321 src: 'https://example.com/encrypted-media.m3u8',
1322 type: 'application/vnd.apple.mpegurl'
1323 });
1324 openMediaSource(player);
1325
1326 var oldMedia = player.hls.playlists.media;
1327 player.hls.playlists.media = function() {
1328 return {
1329 segments: [{
1330 key: {
1331 'method': 'AES-128',
1332 'uri': 'https://priv.example.com/key.php?r=52'
1333 },
1334 uri: 'http://media.example.com/fileSequence52-A.ts'
1335 }, {
1336 key: {
1337 'method': 'AES-128',
1338 'uri': 'https://priv.example.com/key.php?r=53'
1339 },
1340 uri: 'http://media.example.com/fileSequence53-B.ts'
1341 }]
1342 };
1343 };
1344 // we're inject the media playlist, so drop the request
1345 requests.shift();
1346
1347 player.hls.playlists.trigger('loadedplaylist');
1348 // key response
1349 requests[0].response = new Uint32Array([0, 0, 0, 0]).buffer;
1350 requests.shift().respond(200, null, '');
1351 strictEqual(requests.length, 1, 'a key XHR is created');
1352 strictEqual(requests[0].url, player.hls.playlists.media().segments[1].key.uri, 'a key XHR is created with the correct uri');
1353
1354 player.hls.playlists.media = oldMedia;
1355 });
1356
1357 test('calling fetchKeys() when a seek happens will create an XHR', function() {
1358 player.src({
1359 src: 'https://example.com/encrypted-media.m3u8',
1360 type: 'application/vnd.apple.mpegurl'
1361 });
1362 openMediaSource(player);
1363
1364 var oldMedia = player.hls.playlists.media;
1365 player.hls.playlists.media = function() {
1366 return {
1367 segments: [{
1368 duration: 10,
1369 key: {
1370 'method': 'AES-128',
1371 'uri': 'https://priv.example.com/key.php?r=52'
1372 },
1373 uri: 'http://media.example.com/fileSequence52-A.ts'
1374 }, {
1375 duration: 10,
1376 key: {
1377 'method': 'AES-128',
1378 'uri': 'https://priv.example.com/key.php?r=53'
1379 },
1380 uri: 'http://media.example.com/fileSequence53-B.ts'
1381 }]
1382 };
1383 };
1384
1385 player.hls.fetchKeys(player.hls.playlists.media(), 0);
1386 player.currentTime(11);
1387 ok(requests[1].aborted, 'the key XHR should be aborted');
1388 equal(requests.length, 3, 'we should get a new key XHR');
1389 equal(requests[2].url, player.hls.playlists.media().segments[1].key.uri, 'urls should match');
1390
1391 player.hls.playlists.media = oldMedia;
1392 });
1393
1394 test('calling fetchKeys() when a key XHR is in progress will *not* create an XHR', function() {
1395 player.src({
1396 src: 'https://example.com/encrypted-media.m3u8',
1397 type: 'application/vnd.apple.mpegurl'
1398 });
1399 openMediaSource(player);
1400
1401 var oldMedia = player.hls.playlists.media;
1402 player.hls.playlists.media = function() {
1403 return {
1404 segments: [{
1405 key: {
1406 'method': 'AES-128',
1407 'uri': 'https://priv.example.com/key.php?r=52'
1408 },
1409 uri: 'http://media.example.com/fileSequence52-A.ts'
1410 }, {
1411 key: {
1412 'method': 'AES-128',
1413 'uri': 'https://priv.example.com/key.php?r=53'
1414 },
1415 uri: 'http://media.example.com/fileSequence53-B.ts'
1416 }]
1417 };
1418 };
1419
1420 strictEqual(requests.length, 1, 'no key XHR created for the player');
1421 player.hls.playlists.trigger('loadedplaylist');
1422 player.hls.fetchKeys(player.hls.playlists.media(), 0);
1423 strictEqual(requests.length, 2, 'only the original XHR is available');
1424
1425 player.hls.playlists.media = oldMedia;
1426 });
1427
1428 test('calling fetchKeys() when all keys are fetched, will *not* create an XHR', function() {
1429 player.src({
1430 src: 'https://example.com/encrypted-media.m3u8',
1431 type: 'application/vnd.apple.mpegurl'
1432 });
1433 openMediaSource(player);
1434
1435 var oldMedia = player.hls.playlists.media;
1436 player.hls.playlists.media = function() {
1437 return {
1438 segments: [{
1439 key: {
1440 'method': 'AES-128',
1441 'uri': 'https://priv.example.com/key.php?r=52',
1442 bytes: new Uint8Array([1])
1443 },
1444 uri: 'http://media.example.com/fileSequence52-A.ts'
1445 }, {
1446 key: {
1447 'method': 'AES-128',
1448 'uri': 'https://priv.example.com/key.php?r=53',
1449 bytes: new Uint8Array([1])
1450 },
1451 uri: 'http://media.example.com/fileSequence53-B.ts'
1452 }]
1453 };
1454 };
1455
1456 player.hls.fetchKeys(player.hls.playlists.media(), 0);
1457 strictEqual(requests.length, 1, 'no XHR for keys created since they were all downloaded');
1458
1459 player.hls.playlists.media = oldMedia;
1460 });
1461
1462 test('retries key requests once upon failure', function() {
1463 player.src({
1464 src: 'https://example.com/encrypted-media.m3u8',
1465 type: 'application/vnd.apple.mpegurl'
1466 });
1467 openMediaSource(player);
1468
1469 var oldMedia = player.hls.playlists.media;
1470 player.hls.playlists.media = function() {
1471 return {
1472 segments: [{
1473 key: {
1474 'method': 'AES-128',
1475 'uri': 'https://priv.example.com/key.php?r=52'
1476 },
1477 uri: 'http://media.example.com/fileSequence52-A.ts'
1478 }, {
1479 key: {
1480 'method': 'AES-128',
1481 'uri': 'https://priv.example.com/key.php?r=53'
1482 },
1483 uri: 'http://media.example.com/fileSequence53-B.ts'
1484 }]
1485 };
1486 };
1487
1488 player.hls.fetchKeys(player.hls.playlists.media(), 0);
1489
1490 requests[1].respond(404);
1491 equal(requests.length, 3, 'create a new XHR for the same key');
1492 equal(requests[2].url, requests[1].url, 'should be the same key');
1493
1494 requests[2].respond(404);
1495 equal(requests.length, 4, 'create a new XHR for the same key');
1496 notEqual(requests[3].url, requests[2].url, 'should be the same key');
1497 equal(requests[3].url, player.hls.playlists.media().segments[1].key.uri);
1498
1499 player.hls.playlists.media = oldMedia;
1500 });
1501
1502 test('skip segments if key requests fail more than once', function() {
1503 var bytes = [],
1504 tags = [{ pats: 0, bytes: 0 }];
1505
1506 videojs.Hls.SegmentParser = mockSegmentParser(tags);
1507 window.videojs.SourceBuffer = function() {
1508 this.appendBuffer = function(chunk) {
1509 bytes.push(chunk);
1510 };
1511 this.abort = function() {};
1512 };
1513
1514 player.src({
1515 src: 'https://example.com/encrypted-media.m3u8',
1516 type: 'application/vnd.apple.mpegurl'
1517 });
1518 openMediaSource(player);
1519
1520 requests.pop().respond(200, null,
1521 '#EXTM3U\n' +
1522 '#EXT-X-KEY:METHOD=AES-128,URI="htts://priv.example.com/key.php?r=52"\n' +
1523 '#EXTINF:2.833,\n' +
1524 'http://media.example.com/fileSequence52-A.ts\n' +
1525 '#EXT-X-KEY:METHOD=AES-128,URI="htts://priv.example.com/key.php?r=53"\n' +
1526 '#EXTINF:15.0,\n' +
1527 'http://media.example.com/fileSequence53-A.ts\n');
1528
1529 player.hls.playlists.trigger('loadedplaylist');
1530
1531 player.trigger('timeupdate');
1532
1533 // respond to ts segment
1534 standardXHRResponse(requests.pop());
1535 // fail key
1536 requests.pop().respond(404);
1537 // fail key, again
1538 requests.pop().respond(404);
1539
1540 // key for second segment
1541 requests[0].response = new Uint32Array([0,0,0,0]).buffer;
1542 requests[0].respond(200, null, '');
1543 requests.shift();
1544
1545 equal(bytes.length, 1, 'bytes from the ts segments should not be added');
1546
1547 player.trigger('timeupdate');
1548
1549 tags.length = 0;
1550 tags.push({pts: 0, bytes: 1});
1551
1552 // second segment
1553 standardXHRResponse(requests.pop());
1554
1555 equal(bytes.length, 2, 'bytes from the second ts segment should be added');
1556 equal(bytes[1], 1, 'the bytes from the second segment are added and not the first');
1557 });
1558
1559 test('the key is supplied to the decrypter in the correct format', function() {
1560 var keys = [];
1561
1562 player.src({
1563 src: 'https://example.com/encrypted-media.m3u8',
1564 type: 'application/vnd.apple.mpegurl'
1565 });
1566 openMediaSource(player);
1567
1568 requests.pop().respond(200, null,
1569 '#EXTM3U\n' +
1570 '#EXT-X-MEDIA-SEQUENCE:5\n' +
1571 '#EXT-X-KEY:METHOD=AES-128,URI="htts://priv.example.com/key.php?r=52"\n' +
1572 '#EXTINF:2.833,\n' +
1573 'http://media.example.com/fileSequence52-A.ts\n' +
1574 '#EXTINF:15.0,\n' +
1575 'http://media.example.com/fileSequence52-B.ts\n');
1576
1577
1578 videojs.Hls.decrypt = function(bytes, key) {
1579 keys.push(key);
1580 return new Uint8Array([0]);
1581 };
1582
1583 requests[0].response = new Uint32Array([0,1,2,3]).buffer;
1584 requests[0].respond(200, null, '');
1585 requests.shift();
1586 standardXHRResponse(requests.pop());
1587
1588 equal(keys.length, 1, 'only one call to decrypt was made');
1589 deepEqual(keys[0],
1590 new Uint32Array([0, 0x01000000, 0x02000000, 0x03000000]),
1591 'passed the specified segment key');
1592
1593 });
1594 test('supplies the media sequence of current segment as the IV by default, if no IV is specified', function() {
1595 var ivs = [];
1596
1597 player.src({
1598 src: 'https://example.com/encrypted-media.m3u8',
1599 type: 'application/vnd.apple.mpegurl'
1600 });
1601 openMediaSource(player);
1602
1603 requests.pop().respond(200, null,
1604 '#EXTM3U\n' +
1605 '#EXT-X-MEDIA-SEQUENCE:5\n' +
1606 '#EXT-X-KEY:METHOD=AES-128,URI="htts://priv.example.com/key.php?r=52"\n' +
1607 '#EXTINF:2.833,\n' +
1608 'http://media.example.com/fileSequence52-A.ts\n' +
1609 '#EXTINF:15.0,\n' +
1610 'http://media.example.com/fileSequence52-B.ts\n');
1611
1612
1613 videojs.Hls.decrypt = function(bytes, key, iv) {
1614 ivs.push(iv);
1615 return new Uint8Array([0]);
1616 };
1617
1618 requests[0].response = new Uint32Array([0,0,0,0]).buffer;
1619 requests[0].respond(200, null, '');
1620 requests.shift();
1621 standardXHRResponse(requests.pop());
1622
1623 equal(ivs.length, 1, 'only one call to decrypt was made');
1624 deepEqual(ivs[0],
1625 new Uint32Array([0, 0, 0, 5]),
1626 'the IV for the segment is the media sequence');
1627 });
1628
1629 test('switching playlists with an outstanding key request does not stall playback', function() {
1630 var media = '#EXTM3U\n' +
1631 '#EXT-X-MEDIA-SEQUENCE:5\n' +
1632 '#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"\n' +
1633 '#EXTINF:2.833,\n' +
1634 'http://media.example.com/fileSequence52-A.ts\n' +
1635 '#EXTINF:15.0,\n' +
1636 'http://media.example.com/fileSequence52-B.ts\n';
1637 player.src({
1638 src: 'https://example.com/master.m3u8',
1639 type: 'application/vnd.apple.mpegurl'
1640 });
1641 openMediaSource(player);
1642
1643 // master playlist
1644 standardXHRResponse(requests.shift());
1645 // media playlist
1646 requests.shift().respond(200, null, media);
1647 // mock out media switching from this point on
1648 player.hls.playlists.media = function() {
1649 return player.hls.playlists.master.playlists[0];
1650 };
1651 // don't respond to the initial key request
1652 requests.shift();
1653 // first segment of the original media playlist
1654 standardXHRResponse(requests.shift());
1655
1656 // "switch" media
1657 player.hls.playlists.trigger('mediachange');
1658
1659 player.trigger('timeupdate');
1660
1661 ok(requests.length, 'made a request');
1662 equal(requests[0].url,
1663 'https://priv.example.com/key.php?r=52',
1664 'requested the segment and key');
1665 });
1666
1667 test('resovles relative key URLs against the playlist', function() {
1668 player.src({
1669 src: 'https://example.com/media.m3u8',
1670 type: 'application/vnd.apple.mpegurl'
1671 });
1672 openMediaSource(player);
1673
1674 requests.shift().respond(200, null,
1675 '#EXTM3U\n' +
1676 '#EXT-X-MEDIA-SEQUENCE:5\n' +
1677 '#EXT-X-KEY:METHOD=AES-128,URI="key.php?r=52"\n' +
1678 '#EXTINF:2.833,\n' +
1679 'http://media.example.com/fileSequence52-A.ts\n');
1680 equal(requests[0].url, 'https://example.com/key.php?r=52', 'resolves the key URL');
1681 });
1682
1683 test('treats invalid keys as a key request failure', function() {
1684 var tags = [{ pts: 0, bytes: 0 }], bytes = [];
1685 videojs.Hls.SegmentParser = mockSegmentParser(tags);
1686 window.videojs.SourceBuffer = function() {
1687 this.appendBuffer = function(chunk) {
1688 bytes.push(chunk);
1689 };
1690 };
1691 player.src({
1692 src: 'https://example.com/media.m3u8',
1693 type: 'application/vnd.apple.mpegurl'
1694 });
1695 openMediaSource(player);
1696 requests.shift().respond(200, null,
1697 '#EXTM3U\n' +
1698 '#EXT-X-MEDIA-SEQUENCE:5\n' +
1699 '#EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52"\n' +
1700 '#EXTINF:2.833,\n' +
1701 'http://media.example.com/fileSequence52-A.ts\n' +
1702 '#EXT-X-KEY:METHOD=NONE\n' +
1703 '#EXTINF:15.0,\n' +
1704 'http://media.example.com/fileSequence52-B.ts\n');
1705 // keys should be 16 bytes long
1706 requests[0].response = new Uint8Array(1).buffer;
1707 requests.shift().respond(200, null, '');
1708 // segment request
1709 standardXHRResponse(requests.shift());
1710
1711 equal(requests[0].url, 'https://priv.example.com/key.php?r=52', 'retries the key');
1712
1713 // the retried response is invalid, too
1714 requests[0].response = new Uint8Array(1);
1715 requests.shift().respond(200, null, '');
1716
1717 // the first segment should be dropped and playback moves on
1718 player.trigger('timeupdate');
1719 equal(bytes.length, 1, 'did not append bytes');
1720 equal(bytes[0], 'flv', 'appended the flv header');
1721
1722 tags.length = 0;
1723 tags.push({ pts: 1, bytes: 1 });
1724 // second segment request
1725 standardXHRResponse(requests.shift());
1726
1727 equal(bytes.length, 2, 'appended bytes');
1728 equal(1, bytes[1], 'skipped to the second segment');
1729 });
1730
1279 })(window, window.videojs); 1731 })(window, window.videojs);
......