diff --git a/browser/components/loop/modules/LoopRooms.jsm b/browser/components/loop/modules/LoopRooms.jsm index 7396b3fabef5..bdfa291af81f 100644 --- a/browser/components/loop/modules/LoopRooms.jsm +++ b/browser/components/loop/modules/LoopRooms.jsm @@ -8,6 +8,7 @@ const {classes: Cc, interfaces: Ci, utils: Cu} = Components; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); const {MozLoopService, LOOP_SESSION_TYPE} = Cu.import("resource:///modules/loop/MozLoopService.jsm", {}); XPCOMUtils.defineLazyModuleGetter(this, "Promise", @@ -35,6 +36,13 @@ this.EXPORTED_SYMBOLS = ["LoopRooms", "roomsPushNotification"]; // The maximum number of clients that we support currently. const CLIENT_MAX_SIZE = 2; +// Wait at least 5 seconds before doing opportunistic encryption. +const MIN_TIME_BEFORE_ENCRYPTION = 5 * 1000; +// Wait at maximum of 30 minutes before doing opportunistic encryption. +const MAX_TIME_BEFORE_ENCRYPTION = 30 * 60 * 1000; +// Wait time between individual re-encryption cycles (1 second). +const TIME_BETWEEN_ENCRYPTIONS = 1000; + const roomsPushNotification = function(version, channelID) { return LoopRoomsInternal.onNotification(version, channelID); }; @@ -117,6 +125,23 @@ const checkForParticipantsUpdate = function(room, updatedRoom) { } }; +/** + * These are wrappers which can be overriden by tests to allow us to manually + * handle the timeouts. + */ +let timerHandlers = { + /** + * Wrapper for setTimeout. + * + * @param {Function} callback The callback function. + * @param {Number} delay The delay in milliseconds. + * @return {Number} The timer identifier. + */ + startTimer(callback, delay) { + return setTimeout(callback, delay); + } +}; + /** * The Rooms class. * @@ -137,6 +162,19 @@ let LoopRoomsInternal = { return gRoomsCache; }, + /** + * @var {Object} encryptionQueue This stores the list of rooms awaiting + * encryption and associated timers. + */ + encryptionQueue: { + queue: [], + timer: null, + reset: function() { + this.queue = []; + this.timer = null; + } + }, + /** * @var {String} sessionType The type of user session. May be 'FXA' or 'GUEST'. */ @@ -160,6 +198,64 @@ let LoopRoomsInternal = { return count; }, + /** + * Processes the encryption queue. Takes the next item off the queue, + * restarts the timer if necessary. + * + * Although this is only called from a timer callback, it is an async function + * so that tests can call it and be deterministic. + */ + processEncryptionQueue: Task.async(function* () { + let roomToken = this.encryptionQueue.queue.shift(); + + // Performed in sync fashion so that we don't queue a timer until it has + // completed, and to make it easier to run tests. + let roomData = this.rooms.get(roomToken); + + if (roomData) { + try { + // Passing the empty object for roomData is enough for the room to be + // re-encrypted. + yield LoopRooms.promise("update", roomToken, {}); + } catch (error) { + MozLoopService.log.error("Upgrade encryption of room failed", error); + // No need to remove the room from the list as that's done in the shift above. + } + } + + if (this.encryptionQueue.queue.length) { + this.encryptionQueue.timer = + timerHandlers.startTimer(this.processEncryptionQueue.bind(this), TIME_BETWEEN_ENCRYPTIONS); + } else { + this.encryptionQueue.timer = null; + } + }), + + /** + * Queues a room for encryption sometime in the future. This is done so as + * not to overload the server or the browser when we initially request the + * list of rooms. + * + * @param {String} roomToken The token for the room that needs encrypting. + */ + queueForEncryption: function(roomToken) { + if (!this.encryptionQueue.queue.includes(roomToken)) { + this.encryptionQueue.queue.push(roomToken); + } + + // Set up encryption to happen at a random time later. There's a minimum + // wait time - we don't need to do this straight away, so no need if the user + // is starting up. We then add a random factor on top of that. This is to + // try and avoid any potential with a set of clients being restarted at the + // same time and flooding the server. + if (!this.encryptionQueue.timer) { + let waitTime = (MAX_TIME_BEFORE_ENCRYPTION - MIN_TIME_BEFORE_ENCRYPTION) * + Math.random() + MIN_TIME_BEFORE_ENCRYPTION; + this.encryptionQueue.timer = + timerHandlers.startTimer(this.processEncryptionQueue.bind(this), waitTime); + } + }, + /** * Gets or creates a room key for a room. * @@ -312,7 +408,9 @@ let LoopRoomsInternal = { if (fallback) { // Fallback decryption succeeded, so we need to re-encrypt the room key and // save the data back again. - // XXX Bug 1152764 will implement this or make it a separate bug. + MozLoopService.log.debug("Fell back to saved key, queuing for encryption", + roomData.roomToken); + this.queueForEncryption(roomData.roomToken); } else if (!savedRoomKey || key != savedRoomKey) { // Decryption succeeded, but we don't have the right key saved. try { @@ -371,6 +469,10 @@ let LoopRoomsInternal = { }; delete room.roomName; + // This room doesn't have context, so we'll save it for a later encryption + // cycle. + this.queueForEncryption(room.roomToken); + this.saveAndNotifyUpdate(room, isUpdate); } else { // XXX Don't decrypt if same? @@ -725,13 +827,16 @@ let LoopRoomsInternal = { update: function(roomToken, roomData, callback) { let room = this.rooms.get(roomToken); let url = "/rooms/" + encodeURIComponent(roomToken); - if (!room.decryptedContext) { room.decryptedContext = { roomName: roomData.roomName || room.roomName }; } else { - room.decryptedContext.roomName = roomData.roomName || room.roomName; + // room.roomName is the final fallback as this is pre-encryption support. + // Bug 1166283 is tracking the removal of the fallback. + room.decryptedContext.roomName = roomData.roomName || + room.decryptedContext.roomName || + room.roomName; } if (roomData.urls && roomData.urls.length) { // For now we only support adding one URL to the room context. diff --git a/browser/components/loop/test/xpcshell/head.js b/browser/components/loop/test/xpcshell/head.js index dd3a446b944f..59880454224e 100644 --- a/browser/components/loop/test/xpcshell/head.js +++ b/browser/components/loop/test/xpcshell/head.js @@ -18,7 +18,7 @@ Cu.import("resource:///modules/loop/LoopCalls.jsm"); Cu.import("resource:///modules/loop/LoopRooms.jsm"); Cu.import("resource://gre/modules/osfile.jsm"); const { MozLoopServiceInternal } = Cu.import("resource:///modules/loop/MozLoopService.jsm", {}); -const { LoopRoomsInternal } = Cu.import("resource:///modules/loop/LoopRooms.jsm", {}); +const { LoopRoomsInternal, timerHandlers } = Cu.import("resource:///modules/loop/LoopRooms.jsm", {}); XPCOMUtils.defineLazyModuleGetter(this, "MozLoopPushHandler", "resource:///modules/loop/MozLoopPushHandler.jsm"); diff --git a/browser/components/loop/test/xpcshell/test_looprooms.js b/browser/components/loop/test/xpcshell/test_looprooms.js index 5dc67b0a9b21..c58231e06cd8 100644 --- a/browser/components/loop/test/xpcshell/test_looprooms.js +++ b/browser/components/loop/test/xpcshell/test_looprooms.js @@ -9,6 +9,8 @@ Cu.import("resource:///modules/loop/LoopRooms.jsm"); Cu.import("resource:///modules/Chat.jsm"); Cu.import("resource://gre/modules/Promise.jsm"); +timerHandlers.startTimer = callback => callback(); + let openChatOrig = Chat.open; const kGuestKey = "uGIs-kGbYt1hBBwjyW7MLQ"; diff --git a/browser/components/loop/test/xpcshell/test_looprooms_encryption_in_fxa.js b/browser/components/loop/test/xpcshell/test_looprooms_encryption_in_fxa.js index d63df1f90b9e..c3f0ffc2db38 100644 --- a/browser/components/loop/test/xpcshell/test_looprooms_encryption_in_fxa.js +++ b/browser/components/loop/test/xpcshell/test_looprooms_encryption_in_fxa.js @@ -4,6 +4,8 @@ "use strict"; +timerHandlers.startTimer = callback => callback(); + Cu.import("resource://services-common/utils.js"); const { LOOP_ROOMS_CACHE_FILENAME } = Cu.import("resource:///modules/loop/LoopRoomsCache.jsm", {}); diff --git a/browser/components/loop/test/xpcshell/test_looprooms_upgrade_to_encryption.js b/browser/components/loop/test/xpcshell/test_looprooms_upgrade_to_encryption.js new file mode 100644 index 000000000000..13e6ec7c2163 --- /dev/null +++ b/browser/components/loop/test/xpcshell/test_looprooms_upgrade_to_encryption.js @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +Cu.import("resource://services-common/utils.js"); + +const loopCrypto = Cu.import("resource:///modules/loop/crypto.js", {}).LoopCrypto; +const { LOOP_ROOMS_CACHE_FILENAME } = Cu.import("resource:///modules/loop/LoopRoomsCache.jsm", {}); + +let gTimerArgs = []; + +timerHandlers.startTimer = function(callback, delay) { + gTimerArgs.push({callback, delay}); + return gTimerArgs.length; +}; + +let gRoomPatches = []; + +const kContextEnabledPref = "loop.contextInConverations.enabled"; + +const kFxAKey = "uGIs-kGbYt1hBBwjyW7MLQ"; + +// Rooms details as responded by the server. +const kRoomsResponses = new Map([ + ["_nxD4V4FflQ", { + roomToken: "_nxD4V4FflQ", + roomName: "First Room Name", + roomUrl: "http://localhost:3000/rooms/_nxD4V4FflQ" + }], + ["QzBbvGmIZWU", { + roomToken: "QzBbvGmIZWU", + roomName: "Loopy Discussion", + roomUrl: "http://localhost:3000/rooms/QzBbvGmIZWU" + }] +]); + +// This is a cut-down version of the one in test_looprooms.js. +add_task(function* setup_server() { + loopServer.registerPathHandler("/registration", (req, res) => { + res.setStatusLine(null, 200, "OK"); + res.processAsync(); + res.finish(); + }); + + loopServer.registerPathHandler("/rooms", (req, res) => { + res.setStatusLine(null, 200, "OK"); + + res.write(JSON.stringify([...kRoomsResponses.values()])); + + res.processAsync(); + res.finish(); + }); + + function returnRoomDetails(res, roomName) { + roomDetail.roomName = roomName; + res.setStatusLine(null, 200, "OK"); + res.write(JSON.stringify(roomDetail)); + res.processAsync(); + res.finish(); + } + + function getJSONData(body) { + return JSON.parse(CommonUtils.readBytesFromInputStream(body)); + } + + // Add a request handler for each room in the list. + [...kRoomsResponses.values()].forEach(function(room) { + loopServer.registerPathHandler("/rooms/" + encodeURIComponent(room.roomToken), (req, res) => { + let roomDetail = extend({}, room); + if (req.method == "PATCH") { + let data = getJSONData(req.bodyInputStream); + Assert.ok("context" in data, "should have encrypted context"); + gRoomPatches.push(data); + delete roomDetail.roomName; + roomDetail.context = data.context; + res.setStatusLine(null, 200, "OK"); + res.write(JSON.stringify(roomDetail)); + res.processAsync(); + res.finish(); + } else { + res.setStatusLine(null, 200, "OK"); + res.write(JSON.stringify(room)); + res.processAsync(); + res.finish(); + } + }); + }); + + mockPushHandler.registrationPushURL = kEndPointUrl; + + yield MozLoopService.promiseRegisteredWithServers(); +}); + +// Test if getting rooms saves unknown keys correctly. +add_task(function* test_get_rooms_upgrades_to_encryption() { + let rooms = yield LoopRooms.promise("getAll"); + + // Check that we've saved the encryption keys correctly. + Assert.equal(LoopRoomsInternal.encryptionQueue.queue.length, 2, "Should have two rooms queued"); + Assert.equal(gTimerArgs.length, 1, "Should have started a timer"); + + // Now pretend the timer has fired. + yield gTimerArgs[0].callback(); + + Assert.equal(gRoomPatches.length, 1, "Should have patched one room"); + Assert.equal(gTimerArgs.length, 2, "Should have started a second timer"); + + yield gTimerArgs[1].callback(); + + Assert.equal(gRoomPatches.length, 2, "Should have patches a second room"); + Assert.equal(gTimerArgs.length, 2, "Should not have queued another timer"); + + // Now check that we've got the right data stored in the rooms. + rooms = yield LoopRooms.promise("getAll"); + + Assert.equal(rooms.length, 2, "Should have two rooms"); + + // We have to decrypt the info, no other way. + for (let room of rooms) { + let roomData = yield loopCrypto.decryptBytes(room.roomKey, room.context.value); + + Assert.deepEqual(JSON.parse(roomData), + { roomName: kRoomsResponses.get(room.roomToken).roomName }, + "Should have encrypted the data correctly"); + } +}); + +function run_test() { + setupFakeLoopServer(); + + Services.prefs.setCharPref("loop.key.fxa", kFxAKey); + Services.prefs.setBoolPref(kContextEnabledPref, true); + + // Pretend we're signed into FxA. + MozLoopServiceInternal.fxAOAuthTokenData = { token_type: "bearer" }; + MozLoopServiceInternal.fxAOAuthProfile = { email: "fake@invalid.com" }; + + do_register_cleanup(function () { + Services.prefs.clearUserPref(kContextEnabledPref); + Services.prefs.clearUserPref("loop.key.fxa"); + + MozLoopServiceInternal.fxAOAuthTokenData = null; + MozLoopServiceInternal.fxAOAuthProfile = null; + }); + + run_next_test(); +} diff --git a/browser/components/loop/test/xpcshell/xpcshell.ini b/browser/components/loop/test/xpcshell/xpcshell.ini index 93331c2d69d3..953d13a937dc 100644 --- a/browser/components/loop/test/xpcshell/xpcshell.ini +++ b/browser/components/loop/test/xpcshell/xpcshell.ini @@ -9,6 +9,7 @@ skip-if = toolkit == 'gonk' [test_looprooms.js] [test_looprooms_encryption_in_fxa.js] [test_looprooms_first_notification.js] +[test_looprooms_upgrade_to_encryption.js] [test_loopservice_directcall.js] [test_loopservice_dnd.js] [test_loopservice_encryptionkey.js]