217e4482 by David LaPalomento

Chunk up decryption

Split up large segments that need AES-128 decryption and hand control back to the browser between segments. Clean up the decryption block loop a bit since it's no longer critical to finish before the next frame is rendered.
1 parent b56951a6
......@@ -38,7 +38,18 @@
(function(window, videojs, unpad) {
'use strict';
var AES, decrypt;
var AES, AsyncStream, Decrypter, decrypt, ntoh;
/**
* Convert network-order (big-endian) bytes into their little-endian
* representation.
*/
ntoh = function(word) {
return (word << 24) |
((word & 0xff00) << 8) |
((word & 0xff0000) >> 8) |
(word >>> 24);
};
/**
* Schedule out an AES key for both encryption and decryption. This
......@@ -221,7 +232,7 @@ AES.prototype = {
decrypt = function(encrypted, key, initVector) {
var
// word-level access to the encrypted bytes
encrypted32 = new Int32Array(encrypted.buffer),
encrypted32 = new Int32Array(encrypted.buffer, encrypted.byteOffset, encrypted.byteLength >> 2),
decipher = new AES(Array.prototype.slice.call(key)),
......@@ -233,7 +244,6 @@ decrypt = function(encrypted, key, initVector) {
// decrypted data
init0, init1, init2, init3,
encrypted0, encrypted1, encrypted2, encrypted3,
decrypted0, decrypted1, decrypted2, decrypted3,
// iteration variable
wordIx;
......@@ -250,49 +260,25 @@ decrypt = function(encrypted, key, initVector) {
for (wordIx = 0; wordIx < encrypted32.length; wordIx += 4) {
// convert big-endian (network order) words into little-endian
// (javascript order)
encrypted0 = (encrypted32[wordIx] << 24) |
((encrypted32[wordIx] & 0xff00) << 8) |
((encrypted32[wordIx] & 0xff0000) >> 8) |
(encrypted32[wordIx] >>> 24);
encrypted1 = (encrypted32[wordIx + 1] << 24) |
((encrypted32[wordIx + 1] & 0xff00) << 8) |
((encrypted32[wordIx + 1] & 0xff0000) >> 8) |
(encrypted32[wordIx + 1] >>> 24);
encrypted2 = (encrypted32[wordIx + 2] << 24) |
((encrypted32[wordIx + 2] & 0xff00) << 8) |
((encrypted32[wordIx + 2] & 0xff0000) >> 8) |
(encrypted32[wordIx + 2] >>> 24);
encrypted3 = (encrypted32[wordIx + 3] << 24) |
((encrypted32[wordIx + 3] & 0xff00) << 8) |
((encrypted32[wordIx + 3] & 0xff0000) >> 8) |
(encrypted32[wordIx + 3] >>> 24);
encrypted0 = ntoh(encrypted32[wordIx]);
encrypted1 = ntoh(encrypted32[wordIx + 1]);
encrypted2 = ntoh(encrypted32[wordIx + 2]);
encrypted3 = ntoh(encrypted32[wordIx + 3]);
// decrypt the block
decipher.decrypt(encrypted0, encrypted1, encrypted2, encrypted3, decrypted32, wordIx);
decipher.decrypt(encrypted0,
encrypted1,
encrypted2,
encrypted3,
decrypted32,
wordIx);
// XOR with the IV, and restore network byte-order to obtain the
// plaintext
decrypted0 = decrypted32[wordIx] ^ init0;
decrypted1 = decrypted32[wordIx + 1] ^ init1;
decrypted2 = decrypted32[wordIx + 2] ^ init2;
decrypted3 = decrypted32[wordIx + 3] ^ init3;
decrypted32[wordIx] = decrypted0 << 24 |
((decrypted0 & 0xff00) << 8) |
((decrypted0 & 0xff0000) >> 8) |
(decrypted0 >>> 24);
decrypted32[wordIx + 1] = decrypted1 << 24 |
((decrypted1 & 0xff00) << 8) |
((decrypted1 & 0xff0000) >> 8) |
(decrypted1 >>> 24);
decrypted32[wordIx + 2] = decrypted2 << 24 |
((decrypted2 & 0xff00) << 8) |
((decrypted2 & 0xff0000) >> 8) |
(decrypted2 >>> 24);
decrypted32[wordIx + 3] = decrypted3 << 24 |
((decrypted3 & 0xff00) << 8) |
((decrypted3 & 0xff0000) >> 8) |
(decrypted3 >>> 24);
decrypted32[wordIx] = ntoh(decrypted32[wordIx] ^ init0);
decrypted32[wordIx + 1] = ntoh(decrypted32[wordIx + 1] ^ init1);
decrypted32[wordIx + 2] = ntoh(decrypted32[wordIx + 2] ^ init2);
decrypted32[wordIx + 3] = ntoh(decrypted32[wordIx + 3] ^ init3);
// setup the IV for the next round
init0 = encrypted0;
......@@ -301,11 +287,79 @@ decrypt = function(encrypted, key, initVector) {
init3 = encrypted3;
}
// remove any padding
return unpad(decrypted);
return decrypted;
};
AsyncStream = function() {
this.jobs = [];
this.delay = 1;
this.timeout_ = null;
};
AsyncStream.prototype = new videojs.Hls.Stream();
AsyncStream.prototype.processJob_ = function() {
this.jobs.shift()();
if (this.jobs.length) {
this.timeout_ = setTimeout(videojs.bind(this, this.processJob_),
this.delay);
} else {
this.timeout_ = null;
}
};
AsyncStream.prototype.push = function(job) {
this.jobs.push(job);
if (!this.timeout_) {
this.timeout_ = setTimeout(videojs.bind(this, this.processJob_),
this.delay);
}
};
Decrypter = function(encrypted, key, initVector, done) {
var
step = Decrypter.STEP,
encrypted32 = new Int32Array(encrypted.buffer),
decrypted = new Uint8Array(encrypted.byteLength),
i = 0;
this.asyncStream_ = new AsyncStream();
// split up the encryption job and do the individual chunks asynchronously
this.asyncStream_.push(this.decryptChunk_(encrypted32.subarray(i, i + step),
key,
initVector,
decrypted,
i));
for (i = step; i < encrypted32.length; i += step) {
initVector = new Uint32Array([
ntoh(encrypted32[i - 4]),
ntoh(encrypted32[i - 3]),
ntoh(encrypted32[i - 2]),
ntoh(encrypted32[i - 1])
]);
this.asyncStream_.push(this.decryptChunk_(encrypted32.subarray(i, i + step),
key,
initVector,
decrypted));
}
// invoke the done() callback when everything is finished
this.asyncStream_.push(function() {
// remove pkcs#7 padding from the decrypted bytes
done(null, unpad(decrypted));
});
};
Decrypter.prototype = new videojs.Hls.Stream();
Decrypter.prototype.decryptChunk_ = function(encrypted, key, initVector, decrypted) {
return function() {
var bytes = decrypt(encrypted,
key,
initVector);
decrypted.set(bytes, encrypted.byteOffset);
};
};
// the maximum number of bytes to process at one time
Decrypter.STEP = 4 * 8000;
// exports
videojs.Hls.decrypt = decrypt;
videojs.Hls.Decrypter = Decrypter;
videojs.Hls.AsyncStream = AsyncStream;
})(window, window.videojs, window.pkcs7.unpad);
......
......@@ -633,6 +633,8 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, offset) {
responseType: 'arraybuffer',
withCredentials: settings.withCredentials
}, function(error, url) {
var segmentInfo;
// the segment request is no longer outstanding
tech.segmentXhr_ = null;
......@@ -665,12 +667,26 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, offset) {
// if the segment is the start of a timestamp discontinuity,
// we have to wait until the sourcebuffer is empty before
// aborting the source buffer processing
tech.segmentBuffer_.push({
segmentInfo = {
// the segment's mediaIndex at the time it was received
mediaIndex: tech.mediaIndex,
// the segment's playlist
playlist: tech.playlists.media(),
// optionally, a time offset to seek to within the segment
offset: offset,
bytes: new Uint8Array(this.response)
});
// unencrypted bytes of the segment
bytes: null,
// when a key is defined for this segment, the encrypted bytes
encryptedBytes: null,
// optionally, the decrypter that is unencrypting the segment
decrypter: null
};
if (segmentInfo.playlist.segments[segmentInfo.mediaIndex].key) {
segmentInfo.encryptedBytes = new Uint8Array(this.response);
} else {
segmentInfo.bytes = new Uint8Array(this.response);
}
tech.segmentBuffer_.push(segmentInfo);
player.trigger('progress');
tech.drainBuffer();
......@@ -685,12 +701,15 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, offset) {
videojs.Hls.prototype.drainBuffer = function(event) {
var
i = 0,
segmentInfo,
mediaIndex,
playlist,
offset,
tags,
bytes,
segment,
decrypter,
segIv,
ptsTime,
segmentOffset,
......@@ -700,28 +719,45 @@ videojs.Hls.prototype.drainBuffer = function(event) {
return;
}
mediaIndex = segmentBuffer[0].mediaIndex;
playlist = segmentBuffer[0].playlist;
offset = segmentBuffer[0].offset;
bytes = segmentBuffer[0].bytes;
segmentInfo = segmentBuffer[0];
mediaIndex = segmentInfo.mediaIndex;
playlist = segmentInfo.playlist;
offset = segmentInfo.offset;
bytes = segmentInfo.bytes;
segment = playlist.segments[mediaIndex];
if (segment.key) {
if (segment.key && !bytes) {
// this is an encrypted segment
// if the key download failed, we want to skip this segment
// but if the key hasn't downloaded yet, we want to try again later
if (keyFailed(segment.key)) {
return segmentBuffer.shift();
} else if (!segment.key.bytes) {
// trigger a key request if one is not already in-flight
return this.fetchKeys_();
} else if (segmentInfo.decrypter) {
// decryption is in progress, try again later
return;
} else {
// if the media sequence is greater than 2^32, the IV will be incorrect
// assuming 10s segments, that would be about 1300 years
var segIv = segment.key.iv || new Uint32Array([0, 0, 0, mediaIndex + playlist.mediaSequence]);
bytes = videojs.Hls.decrypt(bytes,
segment.key.bytes,
segIv);
segIv = segment.key.iv || new Uint32Array([0, 0, 0, mediaIndex + playlist.mediaSequence]);
// create a decrypter to incrementally decrypt the segment
decrypter = new videojs.Hls.Decrypter(segmentInfo.encryptedBytes,
segment.key.bytes,
segIv,
function(err, bytes) {
segmentInfo.bytes = bytes;
});
segmentInfo.decrypter = decrypter;
return;
}
}
......
(function(window, videojs, undefined) {
(function(window, videojs, unpad, undefined) {
'use strict';
/*
======== A Handy Little QUnit Reference ========
......@@ -46,7 +46,7 @@ test('decrypts a single AES-128 with PKCS7 block', function() {
0x82, 0xa8, 0xf0, 0x67]);
deepEqual('howdy folks',
stringFromBytes(videojs.Hls.decrypt(encrypted, key, initVector)),
stringFromBytes(unpad(videojs.Hls.decrypt(encrypted, key, initVector))),
'decrypted with a byte array key');
});
......@@ -68,8 +68,100 @@ test('decrypts multiple AES-128 blocks with CBC', function() {
]);
deepEqual('0123456789abcdef01234',
stringFromBytes(videojs.Hls.decrypt(encrypted, key, initVector)),
stringFromBytes(unpad(videojs.Hls.decrypt(encrypted, key, initVector))),
'decrypted multiple blocks');
});
})(window, window.videojs);
var clock;
module('Incremental Processing', {
setup: function() {
clock = sinon.useFakeTimers();
},
teardown: function() {
clock.restore();
}
});
test('executes a callback after a timeout', function() {
var asyncStream = new videojs.Hls.AsyncStream(),
calls = '';
asyncStream.push(function() {
calls += 'a';
});
clock.tick(asyncStream.delay);
equal(calls, 'a', 'invoked the callback once');
clock.tick(asyncStream.delay);
equal(calls, 'a', 'only invoked the callback once');
});
test('executes callback in series', function() {
var asyncStream = new videojs.Hls.AsyncStream(),
calls = '';
asyncStream.push(function() {
calls += 'a';
});
asyncStream.push(function() {
calls += 'b';
});
clock.tick(asyncStream.delay);
equal(calls, 'a', 'invoked the first callback');
clock.tick(asyncStream.delay);
equal(calls, 'ab', 'invoked the second');
});
var decrypter;
module('Incremental Decryption', {
setup: function() {
clock = sinon.useFakeTimers();
},
teardown: function() {
clock.restore();
}
});
test('asynchronously decrypts a 4-word block', function() {
var
key = new Uint32Array([0, 0, 0, 0]),
initVector = key,
// the string "howdy folks" encrypted
encrypted = new Uint8Array([
0xce, 0x90, 0x97, 0xd0,
0x08, 0x46, 0x4d, 0x18,
0x4f, 0xae, 0x01, 0x1c,
0x82, 0xa8, 0xf0, 0x67]),
decrypted;
decrypter = new videojs.Hls.Decrypter(encrypted, key, initVector, function(error, result) {
decrypted = result;
});
ok(!decrypted, 'asynchronously decrypts');
clock.tick(decrypter.asyncStream_.delay * 2);
ok(decrypted, 'completed decryption');
deepEqual('howdy folks',
stringFromBytes(decrypted),
'decrypts and unpads the result');
});
test('breaks up input greater than the step value', function() {
var encrypted = new Int32Array(videojs.Hls.Decrypter.STEP + 4),
done = false,
decrypter = new videojs.Hls.Decrypter(encrypted,
new Uint32Array(4),
new Uint32Array(4),
function(error, result) {
done = true;
});
clock.tick(decrypter.asyncStream_.delay * 2);
ok(!done, 'not finished after two ticks');
clock.tick(decrypter.asyncStream_.delay);
ok(done, 'finished after the last chunk is decrypted');
});
})(window, window.videojs, window.pkcs7.unpad);
......
......@@ -152,10 +152,8 @@ module('HLS', {
oldNativeHlsSupport = videojs.Hls.supportsNativeHls;
oldDecrypt = videojs.Hls.decrypt;
videojs.Hls.decrypt = function() {
return new Uint8Array([0]);
};
oldDecrypt = videojs.Hls.Decrypter;
videojs.Hls.Decrypter = function() {};
// fake XHRs
xhr = sinon.useFakeXMLHttpRequest();
......@@ -173,7 +171,7 @@ module('HLS', {
videojs.MediaSource.open = oldMediaSourceOpen;
videojs.Hls.SegmentParser = oldSegmentParser;
videojs.Hls.supportsNativeHls = oldNativeHlsSupport;
videojs.Hls.decrypt = oldDecrypt;
videojs.Hls.Decrypter = oldDecrypt;
videojs.SourceBuffer = oldSourceBuffer;
window.setTimeout = oldSetTimeout;
window.clearTimeout = oldClearTimeout;
......@@ -1878,6 +1876,10 @@ test('a new key XHR is created when a the segment is received', function() {
'#EXT-X-ENDLIST\n');
standardXHRResponse(requests.shift()); // segment 1
standardXHRResponse(requests.shift()); // key 1
// "finish" decrypting segment 1
player.hls.segmentBuffer_[0].bytes = new Uint8Array(16);
player.hls.checkBuffer_();
standardXHRResponse(requests.shift()); // segment 2
strictEqual(requests.length, 1, 'a key XHR is created');
......@@ -1983,6 +1985,9 @@ test('skip segments if key requests fail more than once', function() {
// key for second segment
requests[0].response = new Uint32Array([0,0,0,0]).buffer;
requests.shift().respond(200, null, '');
// "finish" decryption
player.hls.segmentBuffer_[0].bytes = new Uint8Array(16);
player.hls.checkBuffer_();
equal(bytes.length, 2, 'bytes from the second ts segment should be added');
equal(bytes[1], 1, 'the bytes from the second segment are added and not the first');
......@@ -2007,9 +2012,8 @@ test('the key is supplied to the decrypter in the correct format', function() {
'http://media.example.com/fileSequence52-B.ts\n');
videojs.Hls.decrypt = function(bytes, key) {
videojs.Hls.Decrypter = function(encrypted, key) {
keys.push(key);
return new Uint8Array([0]);
};
standardXHRResponse(requests.shift()); // segment
......@@ -2017,7 +2021,7 @@ test('the key is supplied to the decrypter in the correct format', function() {
requests[0].respond(200, null, '');
requests.shift(); // key
equal(keys.length, 1, 'only one call to decrypt was made');
equal(keys.length, 1, 'only one Decrypter was constructed');
deepEqual(keys[0],
new Uint32Array([0, 0x01000000, 0x02000000, 0x03000000]),
'passed the specified segment key');
......@@ -2042,9 +2046,8 @@ test('supplies the media sequence of current segment as the IV by default, if no
'http://media.example.com/fileSequence52-B.ts\n');
videojs.Hls.decrypt = function(bytes, key, iv) {
videojs.Hls.Decrypter = function(encrypted, key, iv) {
ivs.push(iv);
return new Uint8Array([0]);
};
requests[0].response = new Uint32Array([0,0,0,0]).buffer;
......@@ -2052,7 +2055,7 @@ test('supplies the media sequence of current segment as the IV by default, if no
requests.shift();
standardXHRResponse(requests.pop());
equal(ivs.length, 1, 'only one call to decrypt was made');
equal(ivs.length, 1, 'only one Decrypter was constructed');
deepEqual(ivs[0],
new Uint32Array([0, 0, 0, 5]),
'the IV for the segment is the media sequence');
......