Merge pull request #139 from videojs/feature/hlse2
Support segment-level AES-128 encryption
Showing
18 changed files
with
1231 additions
and
32 deletions
... | @@ -11,3 +11,39 @@ distributed under the License is distributed on an "AS IS" BASIS, | ... | @@ -11,3 +11,39 @@ 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. | 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 | ... | ... |
docs/hlse.md
0 → 100644
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 | ``` |
... | @@ -28,6 +28,11 @@ | ... | @@ -28,6 +28,11 @@ |
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 | 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> | ||
35 | |||
31 | <!-- example MPEG2-TS segments --> | 36 | <!-- example MPEG2-TS segments --> |
32 | <!-- bipbop --> | 37 | <!-- bipbop --> |
33 | <!-- <script src="test/tsSegment.js"></script> --> | 38 | <!-- <script src="test/tsSegment.js"></script> --> |
... | @@ -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 | ... | ... |
... | @@ -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,7 +18,7 @@ | ... | @@ -18,7 +18,7 @@ |
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 | } | ... | ... |
src/decrypter.js
0 → 100644
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] === '') { |
36 | continue; | ||
37 | } | ||
21 | 38 | ||
22 | // This is not sexy, but gives us the resulting object we want. | 39 | // split the key and value |
23 | if (attr[1]) { | 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, ''); | ||
24 | attr[1] = attr[1].replace(/^\s+|\s+$/g, ''); | 43 | attr[1] = attr[1].replace(/^\s+|\s+$/g, ''); |
25 | if (attr[1].indexOf('"') !== -1) { | 44 | attr[1] = attr[1].replace(/^['"](.*)['"]$/g, '$1'); |
26 | attr[1] = attr[1].split('"')[1]; | ||
27 | } | ||
28 | result[attr[0]] = attr[1]; | 45 | result[attr[0]] = attr[1]; |
29 | } else { | ||
30 | attrs[i - 1] = attrs[i - 1] + ',' + attr[0]; | ||
31 | } | ||
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 | */ | ... | ... |
test/decrypter_test.js
0 → 100644
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', | ... | ... |
test/manifest/encrypted.json
0 → 100644
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 | } |
test/manifest/encrypted.m3u8
0 → 100644
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,11 +43,21 @@ | ... | @@ -43,11 +43,21 @@ |
43 | <section> | 43 | <section> |
44 | <h2>Inputs</h2> | 44 | <h2>Inputs</h2> |
45 | <form id="inputs"> | 45 | <form id="inputs"> |
46 | <fieldset> | ||
46 | <label> | 47 | <label> |
47 | Your original MP2T segment: | 48 | Your original MP2T segment: |
48 | <input type="file" id="original"> | 49 | <input type="file" id="original"> |
49 | </label> | 50 | </label> |
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> | ||
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: |
53 | <input type="file" id="working"> | 63 | <input type="file" id="working"> |
... | @@ -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); | ... | ... |
-
Please register or sign in to post a comment