Bug 1152764 - Loop should encrypt room context information for rooms that aren't encrypted. r=mikedeboer
authorMark Banner <standard8@mozilla.com>
Wed, 20 May 2015 14:15:20 +0100
changeset 244724 1a2ab884ad450208433118c7ce67415f183700bb
parent 244723 9defb2f73316278e81d05b29a9869c57134df7e2
child 244725 70f56bc3f50d907aaafccc7550e0957d2571bc8f
push id28787
push userkwierso@gmail.com
push dateThu, 21 May 2015 00:04:35 +0000
treeherdermozilla-central@74b944315521 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmikedeboer
bugs1152764
milestone41.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1152764 - Loop should encrypt room context information for rooms that aren't encrypted. r=mikedeboer
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.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.
    *
    * 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]