Bug 1152764 - Loop should encrypt room context information for rooms that aren't encrypted. r=mikedeboer, a=sledru
authorMark Banner <standard8@mozilla.com>
Tue, 26 May 2015 01:13:00 -0400
changeset 274777 dd83b2a89de8b945ba3d56a967dee2d89363e62e
parent 274776 ee464ac5ec3ac6e73c76c819748fb6dcd665de48
child 274778 1136061964e9863cb19012e7accec770ee053613
push id863
push userraliiev@mozilla.com
push dateMon, 03 Aug 2015 13:22:43 +0000
treeherdermozilla-release@f6321b14228d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer, sledru
bugs1152764
milestone40.0a2
Bug 1152764 - Loop should encrypt room context information for rooms that aren't encrypted. r=mikedeboer, a=sledru
browser/components/loop/modules/LoopRooms.jsm
browser/components/loop/test/xpcshell/head.js
browser/components/loop/test/xpcshell/test_looprooms.js
browser/components/loop/test/xpcshell/test_looprooms_encryption_in_fxa.js
browser/components/loop/test/xpcshell/test_looprooms_upgrade_to_encryption.js
browser/components/loop/test/xpcshell/xpcshell.ini
--- 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]