AES-128 decryption
Implement the block level AES decipher and include the routine to do PKCS#7 unpadding.
Showing
8 changed files
with
354 additions
and
6 deletions
... | @@ -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 | ... | ... |
... | @@ -3,15 +3,18 @@ The [HLS spec](http://tools.ietf.org/html/draft-pantos-http-live-streaming-13#se | ... | @@ -3,15 +3,18 @@ The [HLS spec](http://tools.ietf.org/html/draft-pantos-http-live-streaming-13#se |
3 | 3 | ||
4 | ```sh | 4 | ```sh |
5 | # encrypt the text "hello" into a file | 5 | # encrypt the text "hello" into a file |
6 | echo -n "hello" | pkcs7 | openssl enc -aes-128-cbc > hello.encrypted | 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 | ||
7 | 10 | ||
8 | # encrypt some text and get the bytes in a format that can be easily used for | 11 | # xxd is a handy way of translating binary into a format easily consumed by |
9 | # testing in javascript | 12 | # javascript |
10 | echo -n "hello" | pkcs7 | openssl enc -aes-128-cbc | xxd -i | 13 | xxd -i hello.encrypted |
11 | ``` | 14 | ``` |
12 | 15 | ||
13 | Later, you can decrypt it: | 16 | Later, you can decrypt it: |
14 | 17 | ||
15 | ```sh | 18 | ```sh |
16 | cat hello.encrypted | openssl enc -d -aes-128-cbc | 19 | openssl enc -d -nopad -aes-128-cbc -k $KEY -iv $IV |
17 | ``` | 20 | ``` | ... | ... |
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 | if (!this._tables[0][0][0]) { | ||
56 | this._precompute(); | ||
57 | } | ||
58 | |||
59 | var i, j, tmp, | ||
60 | encKey, decKey, | ||
61 | sbox = this._tables[0][4], decTable = this._tables[1], | ||
62 | keyLen = key.length, rcon = 1; | ||
63 | |||
64 | if (keyLen !== 4 && keyLen !== 6 && keyLen !== 8) { | ||
65 | throw new Error("Invalid aes key size"); | ||
66 | } | ||
67 | |||
68 | encKey = key.slice(0); | ||
69 | decKey = []; | ||
70 | this._key = [encKey, decKey]; | ||
71 | |||
72 | // schedule encryption keys | ||
73 | for (i = keyLen; i < 4 * keyLen + 28; i++) { | ||
74 | tmp = encKey[i-1]; | ||
75 | |||
76 | // apply sbox | ||
77 | if (i%keyLen === 0 || (keyLen === 8 && i%keyLen === 4)) { | ||
78 | tmp = sbox[tmp>>>24]<<24 ^ sbox[tmp>>16&255]<<16 ^ sbox[tmp>>8&255]<<8 ^ sbox[tmp&255]; | ||
79 | |||
80 | // shift rows and add rcon | ||
81 | if (i%keyLen === 0) { | ||
82 | tmp = tmp<<8 ^ tmp>>>24 ^ rcon<<24; | ||
83 | rcon = rcon<<1 ^ (rcon>>7)*283; | ||
84 | } | ||
85 | } | ||
86 | |||
87 | encKey[i] = encKey[i-keyLen] ^ tmp; | ||
88 | } | ||
89 | |||
90 | // schedule decryption keys | ||
91 | for (j = 0; i; j++, i--) { | ||
92 | tmp = encKey[j&3 ? i : i - 4]; | ||
93 | if (i<=4 || j<4) { | ||
94 | decKey[j] = tmp; | ||
95 | } else { | ||
96 | decKey[j] = decTable[0][sbox[tmp>>>24 ]] ^ | ||
97 | decTable[1][sbox[tmp>>16 & 255]] ^ | ||
98 | decTable[2][sbox[tmp>>8 & 255]] ^ | ||
99 | decTable[3][sbox[tmp & 255]]; | ||
100 | } | ||
101 | } | ||
102 | }; | ||
103 | |||
104 | AES.prototype = { | ||
105 | /** | ||
106 | * The expanded S-box and inverse S-box tables. These will be computed | ||
107 | * on the client so that we don't have to send them down the wire. | ||
108 | * | ||
109 | * There are two tables, _tables[0] is for encryption and | ||
110 | * _tables[1] is for decryption. | ||
111 | * | ||
112 | * The first 4 sub-tables are the expanded S-box with MixColumns. The | ||
113 | * last (_tables[01][4]) is the S-box itself. | ||
114 | * | ||
115 | * @private | ||
116 | */ | ||
117 | _tables: [[[],[],[],[],[]],[[],[],[],[],[]]], | ||
118 | |||
119 | /** | ||
120 | * Expand the S-box tables. | ||
121 | * | ||
122 | * @private | ||
123 | */ | ||
124 | _precompute: function () { | ||
125 | var encTable = this._tables[0], decTable = this._tables[1], | ||
126 | sbox = encTable[4], sboxInv = decTable[4], | ||
127 | i, x, xInv, d=[], th=[], x2, x4, x8, s, tEnc, tDec; | ||
128 | |||
129 | // Compute double and third tables | ||
130 | for (i = 0; i < 256; i++) { | ||
131 | th[( d[i] = i<<1 ^ (i>>7)*283 )^i]=i; | ||
132 | } | ||
133 | |||
134 | for (x = xInv = 0; !sbox[x]; x ^= x2 || 1, xInv = th[xInv] || 1) { | ||
135 | // Compute sbox | ||
136 | s = xInv ^ xInv<<1 ^ xInv<<2 ^ xInv<<3 ^ xInv<<4; | ||
137 | s = s>>8 ^ s&255 ^ 99; | ||
138 | sbox[x] = s; | ||
139 | sboxInv[s] = x; | ||
140 | |||
141 | // Compute MixColumns | ||
142 | x8 = d[x4 = d[x2 = d[x]]]; | ||
143 | tDec = x8*0x1010101 ^ x4*0x10001 ^ x2*0x101 ^ x*0x1010100; | ||
144 | tEnc = d[s]*0x101 ^ s*0x1010100; | ||
145 | |||
146 | for (i = 0; i < 4; i++) { | ||
147 | encTable[i][x] = tEnc = tEnc<<24 ^ tEnc>>>8; | ||
148 | decTable[i][s] = tDec = tDec<<24 ^ tDec>>>8; | ||
149 | } | ||
150 | } | ||
151 | |||
152 | // Compactify. Considerable speedup on Firefox. | ||
153 | for (i = 0; i < 5; i++) { | ||
154 | encTable[i] = encTable[i].slice(0); | ||
155 | decTable[i] = decTable[i].slice(0); | ||
156 | } | ||
157 | }, | ||
158 | |||
159 | /** | ||
160 | * Decrypt an array of 4 big-endian words. | ||
161 | * @param {Array} data The ciphertext. | ||
162 | * @return {Array} The plaintext. | ||
163 | */ | ||
164 | decrypt:function (input) { | ||
165 | if (input.length !== 4) { | ||
166 | throw new Error("Invalid aes block size"); | ||
167 | } | ||
168 | |||
169 | var key = this._key[1], | ||
170 | // state variables a,b,c,d are loaded with pre-whitened data | ||
171 | a = input[0] ^ key[0], | ||
172 | b = input[3] ^ key[1], | ||
173 | c = input[2] ^ key[2], | ||
174 | d = input[1] ^ key[3], | ||
175 | a2, b2, c2, | ||
176 | |||
177 | nInnerRounds = key.length/4 - 2, | ||
178 | i, | ||
179 | kIndex = 4, | ||
180 | out = [0,0,0,0], | ||
181 | table = this._tables[1], | ||
182 | |||
183 | // load up the tables | ||
184 | t0 = table[0], | ||
185 | t1 = table[1], | ||
186 | t2 = table[2], | ||
187 | t3 = table[3], | ||
188 | sbox = table[4]; | ||
189 | |||
190 | // Inner rounds. Cribbed from OpenSSL. | ||
191 | for (i = 0; i < nInnerRounds; i++) { | ||
192 | a2 = t0[a>>>24] ^ t1[b>>16 & 255] ^ t2[c>>8 & 255] ^ t3[d & 255] ^ key[kIndex]; | ||
193 | b2 = t0[b>>>24] ^ t1[c>>16 & 255] ^ t2[d>>8 & 255] ^ t3[a & 255] ^ key[kIndex + 1]; | ||
194 | c2 = t0[c>>>24] ^ t1[d>>16 & 255] ^ t2[a>>8 & 255] ^ t3[b & 255] ^ key[kIndex + 2]; | ||
195 | d = t0[d>>>24] ^ t1[a>>16 & 255] ^ t2[b>>8 & 255] ^ t3[c & 255] ^ key[kIndex + 3]; | ||
196 | kIndex += 4; | ||
197 | a=a2; b=b2; c=c2; | ||
198 | } | ||
199 | |||
200 | // Last round. | ||
201 | for (i = 0; i < 4; i++) { | ||
202 | out[3 & -i] = | ||
203 | sbox[a>>>24 ]<<24 ^ | ||
204 | sbox[b>>16 & 255]<<16 ^ | ||
205 | sbox[c>>8 & 255]<<8 ^ | ||
206 | sbox[d & 255] ^ | ||
207 | key[kIndex++]; | ||
208 | a2=a; a=b; b=c; c=d; d=a2; | ||
209 | } | ||
210 | |||
211 | return out; | ||
212 | } | ||
213 | }; | ||
214 | |||
215 | decrypt = function(encrypted, key) { | ||
216 | var | ||
217 | view = new DataView(encrypted.buffer), | ||
218 | decrypted = new AES(key) | ||
219 | // convert big-endian input to platform byte order for decryption | ||
220 | .decrypt(new Uint32Array([ | ||
221 | view.getUint32(0), | ||
222 | view.getUint32(4), | ||
223 | view.getUint32(8), | ||
224 | view.getUint32(12) | ||
225 | ])), | ||
226 | bytes = new Uint8Array(encrypted.byteLength); | ||
227 | |||
228 | // convert platform byte order back to big-endian for unpadding | ||
229 | view = new DataView(bytes.buffer); | ||
230 | view.setUint32(0, decrypted[0]); | ||
231 | view.setUint32(4, decrypted[1]); | ||
232 | view.setUint32(8, decrypted[2]); | ||
233 | view.setUint32(12, decrypted[3]); | ||
234 | |||
235 | return unpad(bytes); | ||
236 | }; | ||
237 | |||
238 | // exports | ||
239 | videojs.hls = videojs.util.mergeOptions(videojs.hls, { | ||
240 | decrypt: decrypt | ||
241 | }); | ||
242 | })(window, window.videojs, window.pkcs7.unpad); |
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 | module('Decryption'); | ||
27 | |||
28 | test('decrypts using AES-128 CBC with PKCS7', function() { | ||
29 | // the string "howdy folks" with key and initialization | ||
30 | // vector | ||
31 | var | ||
32 | key = [0, 0, 0, 0], | ||
33 | initVector = key, | ||
34 | encrypted = new Uint8Array([ | ||
35 | 0xce, 0x90, 0x97, 0xd0, | ||
36 | 0x08, 0x46, 0x4d, 0x18, | ||
37 | 0x4f, 0xae, 0x01, 0x1c, | ||
38 | 0x82, 0xa8, 0xf0, 0x67]), | ||
39 | length = 'howdy folks'.length, | ||
40 | plaintext = new Uint8Array(length), | ||
41 | i; | ||
42 | |||
43 | i = length; | ||
44 | while (i--) { | ||
45 | plaintext[i] = 'howdy folks'.charCodeAt(i); | ||
46 | } | ||
47 | |||
48 | // decrypt works on the sjcl example site | ||
49 | // correct output: [1752135524, 2032166511, 1818981125, 84215045] | ||
50 | |||
51 | deepEqual(plaintext, | ||
52 | new Uint8Array(videojs.hls.decrypt(encrypted, key, initVector)), | ||
53 | 'decrypted with a numeric key'); | ||
54 | deepEqual(plaintext, | ||
55 | new Uint8Array(videojs.hls.decrypt(encrypted, key, initVector)), | ||
56 | 'decrypted with a byte array key'); | ||
57 | }); | ||
58 | |||
59 | })(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', | ... | ... |
... | @@ -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> | ... | ... |
-
Please register or sign in to post a comment