Bug 1152764 - Loop should encrypt room context information for rooms that aren't encrypted. r=mikedeboer, a=sledru
--- a/browser/components/loop/modules/LoopRooms.jsm
+++ b/browser/components/loop/modules/LoopRooms.jsm
@@ -3,16 +3,17 @@
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
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",
"resource://gre/modules/Promise.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils",
"resource://services-common/utils.js");
XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() {
const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
@@ -30,16 +31,23 @@ XPCOMUtils.defineLazyModuleGetter(this,
"resource:///modules/loop/crypto.js", "LoopCrypto");
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);
};
// Since the LoopRoomsInternal.rooms map as defined below is a local cache of
// room objects that are retrieved from the server, this is list may become out
// of date. The Push server may notify us of this event, which will set the global
// 'dirty' flag to TRUE.
@@ -113,16 +121,33 @@ const checkForParticipantsUpdate = funct
if (!containsParticipant(updatedRoom, participant)) {
eventEmitter.emit("left", room, participant);
eventEmitter.emit("left:" + room.roomToken, participant);
}
}
};
/**
+ * 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.
*
* Each method that is a member of this class requires the last argument to be a
* callback Function. MozLoopAPI will cause things to break if this invariant is
* violated. You'll notice this as well in the documentation for each method.
*/
let LoopRoomsInternal = {
/**
@@ -133,16 +158,29 @@ let LoopRoomsInternal = {
get roomsCache() {
if (!gRoomsCache) {
gRoomsCache = new LoopRoomsCache();
}
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'.
*/
get sessionType() {
return MozLoopService.userProfile ? LOOP_SESSION_TYPE.FXA :
LOOP_SESSION_TYPE.GUEST;
},
/**
@@ -156,16 +194,74 @@ let LoopRoomsInternal = {
continue;
}
count += room.participants.length;
}
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.indexOf(roomToken) == -1) {
+ 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.
*
* It assumes that the room data is decrypted.
*
* @param {Object} roomData The roomData to get the key for.
* @return {Promise} A promise that is resolved whith the room key.
*/
promiseGetOrCreateRoomKey: Task.async(function* (roomData) {
@@ -307,17 +403,19 @@ let LoopRoomsInternal = {
fallback = true;
}
let decryptedData = yield loopCrypto.decryptBytes(key, roomData.context.value);
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 {
yield this.roomsCache.setKey(this.sessionType, roomData.roomToken, key);
}
catch (error) {
MozLoopService.log.error("Failed to save room key:", error);
}
@@ -366,16 +464,20 @@ let LoopRoomsInternal = {
// No encrypted data, use the old roomName field.
// XXX Bug 1152764 will add functions for automatically encrypting the room
// name.
room.decryptedContext = {
roomName: room.roomName
};
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?
try {
let roomData = yield this.promiseDecryptRoomData(room);
this.saveAndNotifyUpdate(roomData, isUpdate);
} catch (error) {
@@ -720,23 +822,26 @@ let LoopRoomsInternal = {
* will be gone forever.
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`.
*/
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.
room.decryptedContext.urls = [roomData.urls[0]];
}
Task.spawn(function* () {
let {all, encrypted} = yield this.promiseEncryptRoomData(room);
--- a/browser/components/loop/test/xpcshell/head.js
+++ b/browser/components/loop/test/xpcshell/head.js
@@ -13,17 +13,17 @@ Cu.import("resource://gre/modules/Servic
Cu.import("resource://gre/modules/Http.jsm");
Cu.import("resource://testing-common/httpd.js");
Cu.import("resource:///modules/loop/MozLoopService.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
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");
const kMockWebSocketChannelName = "Mock WebSocket Channel";
const kWebSocketChannelContractID = "@mozilla.org/network/protocol;1?name=wss";
const kServerPushUrl = "ws://localhost";
--- a/browser/components/loop/test/xpcshell/test_looprooms.js
+++ b/browser/components/loop/test/xpcshell/test_looprooms.js
@@ -4,16 +4,18 @@
"use strict";
Cu.import("resource://services-common/utils.js");
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";
// Rooms details as responded by the server.
const kRoomsResponses = new Map([
["_nxD4V4FflQ", {
roomToken: "_nxD4V4FflQ",
--- a/browser/components/loop/test/xpcshell/test_looprooms_encryption_in_fxa.js
+++ b/browser/components/loop/test/xpcshell/test_looprooms_encryption_in_fxa.js
@@ -1,14 +1,16 @@
/* 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";
+timerHandlers.startTimer = callback => callback();
+
Cu.import("resource://services-common/utils.js");
const { LOOP_ROOMS_CACHE_FILENAME } = Cu.import("resource:///modules/loop/LoopRoomsCache.jsm", {});
const kContextEnabledPref = "loop.contextInConverations.enabled";
const kFxAKey = "uGIs-kGbYt1hBBwjyW7MLQ";
// Rooms details as responded by the server.
new file mode 100644
--- /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();
+}
--- a/browser/components/loop/test/xpcshell/xpcshell.ini
+++ b/browser/components/loop/test/xpcshell/xpcshell.ini
@@ -4,16 +4,17 @@ tail =
firefox-appdir = browser
skip-if = toolkit == 'gonk'
[test_loopapi_hawk_request.js]
[test_looppush_initialize.js]
[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]
[test_loopservice_hawk_errors.js]
[test_loopservice_hawk_request.js]
[test_loopservice_loop_prefs.js]
[test_loopservice_initialize.js]
[test_loopservice_locales.js]