Bug 1152761 - Add local storage for Loop's room keys in case recovery is required, and handle the recovery. r=mikedeboer
authorMark Banner <standard8@mozilla.com>
Fri, 01 May 2015 13:41:38 +0100
changeset 273933 08e566362fc9c84e76c2586a7e843851330aade4
parent 273932 44e94627981782a8980e45b4cae7fda82b46328b
child 273934 5cffb3b5b2fbc663a7e2fd75bee25449bc3f5e67
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
bugs1152761
milestone40.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 1152761 - Add local storage for Loop's room keys in case recovery is required, and handle the recovery. r=mikedeboer
browser/components/loop/modules/LoopRooms.jsm
browser/components/loop/modules/LoopRoomsCache.jsm
browser/components/loop/moz.build
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/xpcshell.ini
--- a/browser/components/loop/modules/LoopRooms.jsm
+++ b/browser/components/loop/modules/LoopRooms.jsm
@@ -2,28 +2,33 @@
  * 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";
 
 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");
+
 const {MozLoopService, LOOP_SESSION_TYPE} = Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
 XPCOMUtils.defineLazyModuleGetter(this, "Promise",
                                   "resource://gre/modules/Promise.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "Task",
-                                  "resource://gre/modules/Task.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", {});
   return new EventEmitter();
 });
 XPCOMUtils.defineLazyGetter(this, "gLoopBundle", function() {
   return Services.strings.createBundle('chrome://browser/locale/loop/loop.properties');
 });
+
+XPCOMUtils.defineLazyModuleGetter(this, "LoopRoomsCache",
+  "resource:///modules/loop/LoopRoomsCache.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "loopUtils",
   "resource:///modules/loop/utils.js", "utils");
 XPCOMUtils.defineLazyModuleGetter(this, "loopCrypto",
   "resource:///modules/loop/crypto.js", "LoopCrypto");
 
 
 this.EXPORTED_SYMBOLS = ["LoopRooms", "roomsPushNotification"];
 
@@ -36,16 +41,18 @@ const roomsPushNotification = function(v
 
 // 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.
 let gDirty = true;
 // Global variable that keeps track of the currently used account.
 let gCurrentUser = null;
+// Global variable that keeps track of the room cache.
+let gRoomsCache = null;
 
 /**
  * Extend a `target` object with the properties defined in `source`.
  *
  * @param {Object} target The target object to receive properties defined in `source`
  * @param {Object} source The source object to copy properties from
  */
 const extend = function(target, source) {
@@ -118,16 +125,23 @@ const checkForParticipantsUpdate = funct
  * violated. You'll notice this as well in the documentation for each method.
  */
 let LoopRoomsInternal = {
   /**
    * @var {Map} rooms Collection of rooms currently in cache.
    */
   rooms: new Map(),
 
+  get roomsCache() {
+    if (!gRoomsCache) {
+      gRoomsCache = new LoopRoomsCache();
+    }
+    return gRoomsCache;
+  },
+
   /**
    * @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;
   },
 
@@ -270,22 +284,50 @@ let LoopRoomsInternal = {
     if (!roomData.context) {
       return roomData;
     }
 
     if (!roomData.context.wrappedKey) {
       throw new Error("Missing wrappedKey");
     }
 
-    // Bug 1152761 will cause us to additionally store keys locally. We'll
-    // need to add some code for recovery in case decryption fails.
-    let key = yield this.promiseDecryptRoomKey(roomData.context.wrappedKey);
+    let savedRoomKey = yield this.roomsCache.getKey(this.sessionType, roomData.roomToken);
+    let fallback = false;
+    let key;
+
+    try {
+      key = yield this.promiseDecryptRoomKey(roomData.context.wrappedKey);
+    } catch (error) {
+      // If we don't have a key saved, then we can't do anything.
+      if (!savedRoomKey) {
+        throw error;
+      }
+
+      // We failed to decrypt the room key, so has our FxA key changed?
+      // If so, we fall-back to the saved room key.
+      key = savedRoomKey;
+      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.
+    } 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);
+      }
+    }
+
     roomData.roomKey = key;
     roomData.decryptedContext = JSON.parse(decryptedData);
 
     // Strip any existing key from the url.
     roomData.roomUrl = roomData.roomUrl.split("#")[0];
     // Now add the key to the url.
     roomData.roomUrl = roomData.roomUrl + "#" + roomData.roomKey;
 
@@ -332,17 +374,17 @@ let LoopRoomsInternal = {
       this.saveAndNotifyUpdate(room, isUpdate);
     } else {
       // XXX Don't decrypt if same?
       try {
         let roomData = yield this.promiseDecryptRoomData(room);
 
         this.saveAndNotifyUpdate(roomData, isUpdate);
       } catch (error) {
-        MozLoopService.log.error("Failed to decrypt room data: " + error);
+        MozLoopService.log.error("Failed to decrypt room data: ", error);
         // Do what we can to save the room data.
         room.decryptedContext = {};
         this.saveAndNotifyUpdate(room, isUpdate);
       }
     }
   }),
 
   /**
@@ -485,16 +527,19 @@ let LoopRoomsInternal = {
       // Do not keep this value - it is a request to the server.
       delete room.expiresIn;
       this.rooms.set(room.roomToken, room);
 
       if (this.sessionType == LOOP_SESSION_TYPE.GUEST) {
         this.setGuestCreatedRoom(true);
       }
 
+      // Now we've got the room token, we can save the key to disk.
+      yield this.roomsCache.setKey(this.sessionType, room.roomToken, room.roomKey);
+
       eventEmitter.emit("add", room);
       callback(null, room);
     }.bind(this)).catch(callback);
   },
 
   /**
    * Sets whether or not the user has created a room in guest mode.
    *
@@ -695,16 +740,20 @@ let LoopRoomsInternal = {
       };
 
       // If we're not encrypting currently, then only send the roomName.
       // XXX This should go away once bug 1153788 is fixed.
       if (!sendData.context) {
         sendData = {
           roomName: newRoomName
         };
+      } else {
+        // This might be an upgrade to encrypted rename, so store the key
+        // just in case.
+        yield this.roomsCache.setKey(this.sessionType, all.roomToken, all.roomKey);
       }
 
       let response = yield MozLoopService.hawkRequest(this.sessionType,
           url, "PATCH", sendData);
 
       let newRoomData = all;
 
       extend(newRoomData, JSON.parse(response.body));
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/modules/LoopRoomsCache.jsm
@@ -0,0 +1,159 @@
+/* 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";
+
+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");
+const {MozLoopService, LOOP_SESSION_TYPE} =
+  Cu.import("resource:///modules/loop/MozLoopService.jsm", {});
+XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils",
+                                  "resource://services-common/utils.js");
+XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+
+this.EXPORTED_SYMBOLS = ["LoopRoomsCache"];
+
+const LOOP_ROOMS_CACHE_FILENAME = "loopRoomsCache.json";
+
+/**
+ * RoomsCache is a cache for saving simple rooms data to the disk in case we
+ * need it for back-up purposes, e.g. recording room keys for FxA if the user
+ * changes their password.
+ *
+ * The format of the data is:
+ *
+ * {
+ *   <sessionType>: {
+ *     <roomToken>: {
+ *       "key": <roomKey>
+ *     }
+ *   }
+ * }
+ *
+ * It is intended to try and keep the data forward and backwards compatible in
+ * a reasonable manner, hence why the structure is more complex than it needs
+ * to be to store tokens and keys.
+ *
+ * @param {Object} options The options for the RoomsCache, containing:
+ *   - {String} baseDir   The base directory in which to save the file.
+ *   - {String} filename  The filename for the cache file.
+ */
+function LoopRoomsCache(options) {
+  options = options || {};
+
+  this.baseDir = options.baseDir || OS.Constants.Path.profileDir;
+  this.path = OS.Path.join(
+    this.baseDir,
+    options.filename || LOOP_ROOMS_CACHE_FILENAME
+  );
+  this._cache = null;
+}
+
+LoopRoomsCache.prototype = {
+  /**
+   * Updates the local copy of the cache and saves it to disk.
+   *
+   * @param  {Object} contents An object to be saved in json format.
+   * @return {Promise} A promise that is resolved once the save is complete.
+   */
+  _setCache: function(contents) {
+    this._cache = contents;
+
+    return OS.File.makeDir(this.baseDir, {ignoreExisting: true}).then(() => {
+        return CommonUtils.writeJSON(contents, this.path);
+      });
+  },
+
+  /**
+   * Returns the local copy of the cache if there is one, otherwise it reads
+   * it from the disk.
+   *
+   * @return {Promise} A promise that is resolved once the read is complete.
+   */
+  _getCache: Task.async(function* () {
+    if (this._cache) {
+      return this._cache;
+    }
+
+    try {
+      return (this._cache = yield CommonUtils.readJSON(this.path));
+    } catch(error) {
+      // This is really complex due to OSFile's error handling, see bug 1160109.
+      if ((OS.Constants.libc && error.unixErrno != OS.Constants.libc.ENOENT) ||
+          (OS.Constants.Win && error.winLastError != OS.Constants.Win.ERROR_FILE_NOT_FOUND)) {
+        MozLoopService.log.debug("Error reading the cache:", error);
+      }
+      return (this._cache = {});
+    }
+  }),
+
+  /**
+   * Function for testability purposes. Clears the cache.
+   *
+   * @return {Promise} A promise that is resolved once the clear is complete.
+   */
+  clear: function() {
+    this._cache = null;
+    return OS.File.remove(this.path);
+  },
+
+  /**
+   * Gets a room key from the cache.
+   *
+   * @param {LOOP_SESSION_TYPE} sessionType  The session type for the room.
+   * @param {String}            roomToken    The token for the room.
+   * @return {Promise} A promise that is resolved when the data has been read
+   *                   with the value of the key, or null if it isn't present.
+   */
+  getKey: Task.async(function* (sessionType, roomToken) {
+    if (sessionType != LOOP_SESSION_TYPE.FXA) {
+      return null;
+    }
+
+    let sessionData = (yield this._getCache())[sessionType];
+
+    if (!sessionData || !sessionData[roomToken]) {
+      return null;
+    }
+    return sessionData[roomToken].key;
+  }),
+
+  /**
+   * Stores a room key into the cache. Note, if the key has not changed,
+   * the store will not be re-written.
+   *
+   * @param {LOOP_SESSION_TYPE} sessionType  The session type for the room.
+   * @param {String}            roomToken    The token for the room.
+   * @param {String}            roomKey      The encryption key for the room.
+   * @return {Promise} A promise that is resolved when the data has been stored.
+   */
+  setKey: Task.async(function* (sessionType, roomToken, roomKey) {
+    if (sessionType != LOOP_SESSION_TYPE.FXA) {
+      return;
+    }
+
+    let cache = yield this._getCache();
+
+    // Create these objects if they don't exist.
+    // We aim to do this creation and setting of the room key in a
+    // forwards-compatible way so that if new fields are added to rooms later
+    // then we don't mess them up (if there's no keys).
+    if (!cache[sessionType]) {
+      cache[sessionType] = {};
+    }
+
+    if (!cache[sessionType][roomToken]) {
+      cache[sessionType][roomToken] = {};
+    }
+
+    // Only save it if there's no key, or it is different.
+    if (!cache[sessionType][roomToken].key ||
+        cache[sessionType][roomToken].key != roomKey) {
+      cache[sessionType][roomToken].key = roomKey;
+      return yield this._setCache(cache);
+    }
+  })
+};
--- a/browser/components/loop/moz.build
+++ b/browser/components/loop/moz.build
@@ -15,16 +15,17 @@ BROWSER_CHROME_MANIFESTS += [
 EXTRA_JS_MODULES.loop += [
     'content/shared/js/crypto.js',
     'content/shared/js/utils.js',
     'modules/CardDavImporter.jsm',
     'modules/GoogleImporter.jsm',
     'modules/LoopCalls.jsm',
     'modules/LoopContacts.jsm',
     'modules/LoopRooms.jsm',
+    'modules/LoopRoomsCache.jsm',
     'modules/LoopStorage.jsm',
     'modules/MozLoopAPI.jsm',
     'modules/MozLoopPushHandler.jsm',
     'modules/MozLoopService.jsm',
     'modules/MozLoopWorker.js',
 ]
 
 with Files('**'):
--- a/browser/components/loop/test/xpcshell/head.js
+++ b/browser/components/loop/test/xpcshell/head.js
@@ -1,24 +1,29 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
+// Initialize this before the imports, as some of them need it.
+do_get_profile();
+
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 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", {});
 
 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";
@@ -206,8 +211,15 @@ MockWebSocketChannel.prototype = {
   stop: function (err) {
     this.listener.onStop(this.context, err || -1);
   },
 
   serverClose: function (err) {
     this.listener.onServerClose(this.context, err || -1);
   },
 };
+
+const extend = function(target, source) {
+  for (let key of Object.getOwnPropertyNames(source)) {
+    target[key] = source[key];
+  }
+  return target;
+};
--- a/browser/components/loop/test/xpcshell/test_looprooms.js
+++ b/browser/components/loop/test/xpcshell/test_looprooms.js
@@ -177,23 +177,16 @@ const kCreateRoomData = {
   roomToken: "_nxD4V4FflQ",
   roomUrl: "http://localhost:3000/rooms/_nxD4V4FflQ",
   expiresAt: 1405534180
 };
 
 const kChannelGuest = MozLoopService.channelIDs.roomsGuest;
 const kChannelFxA = MozLoopService.channelIDs.roomsFxA;
 
-const extend = function(target, source) {
-  for (let key of Object.getOwnPropertyNames(source)) {
-    target[key] = source[key];
-  }
-  return target;
-};
-
 const normalizeRoom = function(room) {
   let newRoom = extend({}, room);
   let name = newRoom.decryptedContext.roomName;
 
   for (let key of Object.getOwnPropertyNames(roomDetail)) {
     // Handle sub-objects if necessary (e.g. context, decryptedContext).
     if (typeof roomDetail[key] == "object") {
       newRoom[key] = extend({}, roomDetail[key]);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/xpcshell/test_looprooms_encryption_in_fxa.js
@@ -0,0 +1,269 @@
+/* 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 { 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.
+const kRoomsResponses = new Map([
+  ["_nxD4V4FflQ", {
+    // Encrypted with roomKey "FliIGLUolW-xkKZVWstqKw".
+    // roomKey is wrapped with kFxAKey.
+    context: {
+      wrappedKey: "F3V27oPB+FgjFbVPML2PupONYqoIZ53XRU4BqG46Lr3eyIGumgCEqgjSe/MXAXiQ//8=",
+      value: "df7B4SNxhOI44eJjQavCevADyCCxz6/DEZbkOkRUMVUxzS42FbzN6C2PqmCKDYUGyCJTwJ0jln8TLw==",
+      alg: "AES-GCM"
+    },
+    roomToken: "_nxD4V4FflQ",
+    roomUrl: "http://localhost:3000/rooms/_nxD4V4FflQ"
+  }],
+  ["QzBbvGmIZWU", {
+    context: {
+      wrappedKey: "AFu7WwFNjhWR5J6L8ks7S6H/1ktYVEw3yt1eIIWVaMabZaB3vh5612/FNzua4oS2oCM=",
+      value: "sqj+xRNEty8K3Q1gSMd5bIUYKu34JfiO2+LIMlJrOetFIbJdBoQ+U8JZNaTFl6Qp3RULZ41x0zeSBSk=",
+      alg: "AES-GCM"
+    },
+    roomToken: "QzBbvGmIZWU",
+    roomUrl: "http://localhost:3000/rooms/QzBbvGmIZWU"
+  }]
+]);
+
+const kExpectedRooms = new Map([
+  ["_nxD4V4FflQ", {
+    context: {
+      wrappedKey: "F3V27oPB+FgjFbVPML2PupONYqoIZ53XRU4BqG46Lr3eyIGumgCEqgjSe/MXAXiQ//8=",
+      value: "df7B4SNxhOI44eJjQavCevADyCCxz6/DEZbkOkRUMVUxzS42FbzN6C2PqmCKDYUGyCJTwJ0jln8TLw==",
+      alg: "AES-GCM"
+    },
+    decryptedContext: {
+      roomName: "First Room Name"
+    },
+    roomKey: "FliIGLUolW-xkKZVWstqKw",
+    roomToken: "_nxD4V4FflQ",
+    roomUrl: "http://localhost:3000/rooms/_nxD4V4FflQ#FliIGLUolW-xkKZVWstqKw"
+  }],
+  ["QzBbvGmIZWU", {
+    context: {
+      wrappedKey: "AFu7WwFNjhWR5J6L8ks7S6H/1ktYVEw3yt1eIIWVaMabZaB3vh5612/FNzua4oS2oCM=",
+      value: "sqj+xRNEty8K3Q1gSMd5bIUYKu34JfiO2+LIMlJrOetFIbJdBoQ+U8JZNaTFl6Qp3RULZ41x0zeSBSk=",
+      alg: "AES-GCM"
+    },
+    decryptedContext: {
+      roomName: "Loopy Discussion",
+    },
+    roomKey: "h2H8Sa9QxLCTTiXNmJVtRA",
+    roomToken: "QzBbvGmIZWU",
+    roomUrl: "http://localhost:3000/rooms/QzBbvGmIZWU"
+  }]
+]);
+
+const kCreateRoomProps = {
+  decryptedContext: {
+    roomName: "Say Hello",
+  },
+  roomOwner: "Gavin",
+  maxSize: 2
+};
+
+const kCreateRoomData = {
+  roomToken: "Vo2BFQqIaAM",
+  roomUrl: "http://localhost:3000/rooms/_nxD4V4FflQ",
+  expiresAt: 1405534180
+};
+
+function getCachePath() {
+  return OS.Path.join(OS.Constants.Path.profileDir, LOOP_ROOMS_CACHE_FILENAME);
+}
+
+function readRoomsCache() {
+  return CommonUtils.readJSON(getCachePath());
+}
+
+function saveRoomsCache(contents) {
+  delete LoopRoomsInternal.roomsCache._cache;
+  return CommonUtils.writeJSON(contents, getCachePath());
+}
+
+function clearRoomsCache() {
+  return LoopRoomsInternal.roomsCache.clear();
+}
+
+// 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");
+
+    if (req.method == "POST") {
+      Assert.ok(req.bodyInputStream, "POST request should have a payload");
+      let body = CommonUtils.readBytesFromInputStream(req.bodyInputStream);
+      let data = JSON.parse(body);
+
+      Assert.ok(!("decryptedContext" in data), "should not have any decrypted data");
+      Assert.ok("context" in data, "should have context");
+
+      res.write(JSON.stringify(kCreateRoomData));
+    } else {
+      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) => {
+      if (req.method == "POST") {
+        let data = getJSONData(req.bodyInputStream);
+        res.setStatusLine(null, 200, "OK");
+        res.write(JSON.stringify(data));
+        res.processAsync();
+        res.finish();
+      } else if (req.method == "PATCH") {
+        let data = getJSONData(req.bodyInputStream);
+        Assert.ok("context" in data, "should have encrypted context");
+        // We return a fake encrypted name here as the context is
+        // encrypted.
+        returnRoomDetails(res, "fakeEncrypted");
+      } else {
+        res.setStatusLine(null, 200, "OK");
+        res.write(JSON.stringify(room));
+        res.processAsync();
+        res.finish();
+      }
+    });
+  });
+
+  loopServer.registerPathHandler("/rooms/error401", (req, res) => {
+    res.setStatusLine(null, 401, "Not Found");
+    res.processAsync();
+    res.finish();
+  });
+
+  loopServer.registerPathHandler("/rooms/errorMalformed", (req, res) => {
+    res.setStatusLine(null, 200, "OK");
+    res.write("{\"some\": \"Syntax Error!\"}}}}}}");
+    res.processAsync();
+    res.finish();
+  });
+
+  mockPushHandler.registrationPushURL = kEndPointUrl;
+
+  yield MozLoopService.promiseRegisteredWithServers();
+});
+
+
+// Test if getting rooms saves unknown keys correctly.
+add_task(function* test_get_rooms_saves_unknown_keys() {
+  let rooms = yield LoopRooms.promise("getAll");
+
+  // Check that we've saved the encryption keys correctly.
+  let roomsCache = yield readRoomsCache();
+  for (let room of [...kExpectedRooms.values()]) {
+    if (room.context.wrappedKey) {
+      Assert.equal(roomsCache[LOOP_SESSION_TYPE.FXA][room.roomToken].key, room.roomKey);
+    }
+  }
+
+  yield clearRoomsCache();
+});
+
+// Test that when we get a room it updates the saved key if it is different.
+add_task(function* test_get_rooms_saves_different_keys() {
+  let roomsCache = {};
+  roomsCache[LOOP_SESSION_TYPE.FXA] = {
+    QzBbvGmIZWU: {key: "fakeKey"}
+  };
+  yield saveRoomsCache(roomsCache);
+
+  const kRoomToken = "QzBbvGmIZWU";
+
+  let room = yield LoopRooms.promise("get", kRoomToken);
+
+  // Check that we've saved the encryption keys correctly.
+  roomsCache = yield readRoomsCache();
+
+  Assert.notEqual(roomsCache[LOOP_SESSION_TYPE.FXA][kRoomToken].key, "fakeKey");
+  Assert.equal(roomsCache[LOOP_SESSION_TYPE.FXA][kRoomToken].key, room.roomKey);
+
+  yield clearRoomsCache();
+});
+
+// Test that if roomKey decryption fails, the saved key is used for decryption.
+add_task(function* test_get_rooms_uses_saved_key() {
+  const kRoomToken = "_nxD4V4FflQ";
+  const kExpected = kExpectedRooms.get(kRoomToken);
+
+  let roomsCache = {};
+  roomsCache[LOOP_SESSION_TYPE.FXA] = {
+    "_nxD4V4FflQ": {key: kExpected.roomKey}
+  };
+  yield saveRoomsCache(roomsCache);
+
+  // Change the encryption key for FxA, so that decoding the room key will break.
+  Services.prefs.setCharPref("loop.key.fxa", "invalidKey");
+
+  let room = yield LoopRooms.promise("get", kRoomToken);
+
+  Assert.deepEqual(room, kExpected);
+
+  Services.prefs.setCharPref("loop.key.fxa", kFxAKey);
+  yield clearRoomsCache();
+});
+
+// Test that when a room is created the new key is saved.
+add_task(function* test_create_room_saves_key() {
+  let room = yield LoopRooms.promise("create", kCreateRoomProps);
+
+  let roomsCache = yield readRoomsCache();
+
+  Assert.equal(roomsCache[LOOP_SESSION_TYPE.FXA][room.roomToken].key, room.roomKey);
+
+  yield clearRoomsCache();
+});
+
+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
@@ -2,16 +2,17 @@
 head = head.js
 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_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]