Bug 1321570 - use dependency injection for fxAccounts, r?kmag draft
authorEthan Glasser-Camp <eglassercamp@mozilla.com>
Tue, 10 Jan 2017 16:44:01 -0500
changeset 484828 e0bf4e6a28a6f8aa9b6db5af5cd6638b6217e3e2
parent 484096 2fa8f94ecda0eb02a4b74e7bb3eb1dfeace08595
child 484829 28caa243fbb5a2e36b44da0ea2479628ed608b6f
push id45560
push usereglassercamp@mozilla.com
push dateWed, 15 Feb 2017 21:09:05 +0000
reviewerskmag
bugs1321570
milestone54.0a1
Bug 1321570 - use dependency injection for fxAccounts, r?kmag Get rid of the ugly hack where test code monkeypatches a singleton to convince the rest of the ExtensionStorageSync code that a fake user is logged in. Instead, take a handle to the fxAccounts service at construction time. Test code can provide any kind of fxAccounts it wants, including one that has a hard-coded user. This provokes a bunch of changes: - ExtensionStorageSync is now no longer a singleton, but a class. You have to instantiate it in order to do anything with it. A global instance extensionStorageSync is provided for use by Sync code as well as WebExtension code. - CryptoCollection is now also a class, and each ExtensionStorageSync instance has its own CryptoCollection. This dependency should maybe also be injected, but for the time being it doesn't provide us any value to do so. - Converting singletons with asynchronous methods to classes is a pain in the neck. We convert async method foo from `foo: Task.async(....)` to `async foo() { .... }`. While we're here, convert KeyRingEncryptionRemoteTransformer#decode to async/await to eliminate a need for `let self = this`. - Update Sync code and WebExtension code to use extensionStorageSync. - There's a cyclic dependency where CryptoCollection#sync depends on ExtensionStorageSync#_syncCollection which depends on CryptoCollection#getKeyRing. As a short-term hack, we now require an ExtensionStorageSync argument to CryptoCollection#sync. - KeyRingEncryptionRemoteTransformer now takes a handle to fxAccounts on construction time as well. Because this is the only EncryptionRemoteTransformer subclass that accesses fxAccounts, we can get rid of the hack where the tests monkeypatch something in the EncryptionRemoteTransformer prototype. - CollectionKeyEncryptionRemoteTransformer now takes a handle to a CryptoCollection, rather than relying on a global one. - A bunch of methods that previously assumed access to fxAccounts now check if fxAccounts is present (i.e. if we're on Android). Strictly speaking, this isn't required by this change, but it helps make certain kinds of failure a little easier to diagnose. - Update tests, including extracting a domain-specific helper method to hide the use of CollectionKeyEncryptionRemoteTransformer. We now no longer monkeypatch in our mock fxaService, but pass it to the test so that it can do whatever it wants with it. We also provide an ExtensionStorageSync instance for convenience. Access to the global cryptoCollection is now done through an ExtensionStorageSync instance. To summarize, we've gone from a situation where a bunch of singletons had implicit dependencies on other singletons in a shared global namespace, to a situation where dependencies are provided explicitly using method/constructor arguments. This highlights some of the dependencies present: - ExtensionStorageSync depends on CryptoCollection and fxAccounts if it needs to sync - Every collection created via openCollection needs a handle to CryptoCollection so it can correctly create its remote transformers - CryptoCollection needs a handle to fxAccounts so it can create its own remote transformer for its special collection Most of this is only possible, or at least much easier, because we no longer try to juggle Sqlite connections but just keep one around forever. However, please note: - CryptoCollection needs a handle to ExtensionStorageSync to actually perform syncing logic because that's where we foolishly put the logic to make requests - There's still a backing Sqlite store which is shared by everything - There's still a singleton tracking contexts that opened extensions which we manage to try to clean contexts up correctly MozReview-Commit-ID: DGIzyRTdYZ1
services/sync/modules/engines/extension-storage.js
services/sync/tests/unit/test_extension_storage_engine.js
services/sync/tests/unit/test_extension_storage_tracker.js
toolkit/components/extensions/ExtensionStorageSync.jsm
toolkit/components/extensions/ext-storage.js
toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js
--- a/services/sync/modules/engines/extension-storage.js
+++ b/services/sync/modules/engines/extension-storage.js
@@ -7,17 +7,17 @@
 this.EXPORTED_SYMBOLS = ["ExtensionStorageEngine"];
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-common/async.js");
-XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorageSync",
+XPCOMUtils.defineLazyModuleGetter(this, "extensionStorageSync",
                                   "resource://gre/modules/ExtensionStorageSync.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
                                   "resource://gre/modules/FxAccounts.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                   "resource://gre/modules/Task.jsm");
 
 /**
  * The Engine that manages syncing for the web extension "storage"
@@ -36,17 +36,17 @@ ExtensionStorageEngine.prototype = {
   // we don't need these since we implement our own sync logic
   _storeObj: undefined,
   _recordObj: undefined,
 
   syncPriority: 10,
   allowSkippedRecord: false,
 
   _sync() {
-    return Async.promiseSpinningly(ExtensionStorageSync.syncAll());
+    return Async.promiseSpinningly(extensionStorageSync.syncAll());
   },
 
   get enabled() {
     // By default, we sync extension storage if we sync addons. This
     // lets us simplify the UX since users probably don't consider
     // "extension preferences" a separate category of syncing.
     // However, we also respect engine.extension-storage.force, which
     // can be set to true or false, if a power user wants to customize
--- a/services/sync/tests/unit/test_extension_storage_engine.js
+++ b/services/sync/tests/unit/test_extension_storage_engine.js
@@ -4,16 +4,17 @@
 "use strict";
 
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/engines/extension-storage.js");
 Cu.import("resource://services-sync/service.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://testing-common/services/sync/utils.js");
 Cu.import("resource://gre/modules/ExtensionStorageSync.jsm");
+/* globals extensionStorageSync */
 
 Service.engineManager.register(ExtensionStorageEngine);
 const engine = Service.engineManager.get("extension-storage");
 do_get_profile();   // so we can use FxAccounts
 loadWebExtensionTestFunctions();
 
 function mock(options) {
   let calls = [];
@@ -41,22 +42,22 @@ add_task(function* test_calling_sync_cal
   } finally {
     ExtensionStorageEngine.prototype._sync = oldSync;
   }
   equal(syncMock.calls.length, 1);
 });
 
 add_task(function* test_calling_sync_calls_ext_storage_sync() {
   const extension = {id: "my-extension"};
-  let oldSync = ExtensionStorageSync.syncAll;
-  let syncMock = ExtensionStorageSync.syncAll = mock({returns: Promise.resolve()});
+  let oldSync = extensionStorageSync.syncAll;
+  let syncMock = extensionStorageSync.syncAll = mock({returns: Promise.resolve()});
   try {
     yield* withSyncContext(function* (context) {
       // Set something so that everyone knows that we're using storage.sync
-      yield ExtensionStorageSync.set(extension, {"a": "b"}, context);
+      yield extensionStorageSync.set(extension, {"a": "b"}, context);
 
       yield engine._sync();
     });
   } finally {
-    ExtensionStorageSync.syncAll = oldSync;
+    extensionStorageSync.syncAll = oldSync;
   }
   do_check_true(syncMock.calls.length >= 1);
 });
--- a/services/sync/tests/unit/test_extension_storage_tracker.js
+++ b/services/sync/tests/unit/test_extension_storage_tracker.js
@@ -4,34 +4,35 @@
 "use strict";
 
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/engines/extension-storage.js");
 Cu.import("resource://services-sync/service.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://gre/modules/ExtensionStorageSync.jsm");
+/* globals extensionStorageSync */
 
 Service.engineManager.register(ExtensionStorageEngine);
 const engine = Service.engineManager.get("extension-storage");
 do_get_profile();   // so we can use FxAccounts
 loadWebExtensionTestFunctions();
 
 add_task(function* test_changing_extension_storage_changes_score() {
   const tracker = engine._tracker;
   const extension = {id: "my-extension-id"};
   Svc.Obs.notify("weave:engine:start-tracking");
   yield* withSyncContext(function*(context) {
-    yield ExtensionStorageSync.set(extension, {"a": "b"}, context);
+    yield extensionStorageSync.set(extension, {"a": "b"}, context);
   });
   do_check_eq(tracker.score, SCORE_INCREMENT_MEDIUM);
 
   tracker.resetScore();
   yield* withSyncContext(function*(context) {
-    yield ExtensionStorageSync.remove(extension, "a", context);
+    yield extensionStorageSync.remove(extension, "a", context);
   });
   do_check_eq(tracker.score, SCORE_INCREMENT_MEDIUM);
 
   Svc.Obs.notify("weave:engine:stop-tracking");
 });
 
 function run_test() {
   run_next_test();
--- a/toolkit/components/extensions/ExtensionStorageSync.jsm
+++ b/toolkit/components/extensions/ExtensionStorageSync.jsm
@@ -4,17 +4,17 @@
 
 // TODO:
 // * find out how the Chrome implementation deals with conflicts
 
 "use strict";
 
 /* exported extensionIdToCollectionId */
 
-this.EXPORTED_SYMBOLS = ["ExtensionStorageSync"];
+this.EXPORTED_SYMBOLS = ["ExtensionStorageSync", "extensionStorageSync"];
 
 const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 const global = this;
 
 Cu.import("resource://gre/modules/AppConstants.jsm");
@@ -29,19 +29,18 @@ const STORAGE_SYNC_CRYPTO_KEYRING_RECORD
 const STORAGE_SYNC_CRYPTO_SALT_LENGTH_BYTES = 32;
 const FXA_OAUTH_OPTIONS = {
   scope: STORAGE_SYNC_SCOPE,
 };
 // Default is 5sec, which seems a bit aggressive on the open internet
 const KINTO_REQUEST_TIMEOUT = 30000;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-const {
-  runSafeSyncWithoutClone,
-} = Cu.import("resource://gre/modules/ExtensionUtils.jsm", {});
+Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+
 
 XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown",
                                   "resource://gre/modules/AsyncShutdown.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "BulkKeyBundle",
                                   "resource://services-sync/keys.js");
 XPCOMUtils.defineLazyModuleGetter(this, "CollectionKeyManager",
                                   "resource://services-sync/record.js");
 XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils",
@@ -70,40 +69,93 @@ XPCOMUtils.defineLazyModuleGetter(this, 
                                   "resource://gre/modules/Task.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Utils",
                                   "resource://services-sync/util.js");
 XPCOMUtils.defineLazyPreferenceGetter(this, "prefPermitsStorageSync",
                                       STORAGE_SYNC_ENABLED_PREF, true);
 XPCOMUtils.defineLazyPreferenceGetter(this, "prefStorageSyncServerURL",
                                       STORAGE_SYNC_SERVER_URL_PREF,
                                       KINTO_DEFAULT_SERVER_URL);
+const {
+  runSafeSyncWithoutClone,
+} = ExtensionUtils;
 
 /* globals prefPermitsStorageSync, prefStorageSyncServerURL */
 
 // Map of Extensions to Set<Contexts> to track contexts that are still
 // "live" and use storage.sync.
 const extensionContexts = new Map();
 // Borrow logger from Sync.
 const log = Log.repository.getLogger("Sync.Engine.Extension-Storage");
 
+// A global that is fxAccounts, or null if (as on android) fxAccounts
+// isn't available.
+let _fxaService = null;
+if (AppConstants.platform != "android") {
+  _fxaService = fxAccounts;
+}
+
+/**
+ * Check for FXA and throw an exception if we don't have access.
+ *
+ * @param {Object} fxAccounts  The reference we were hoping to use to
+ *     access FxA
+ * @param {string} action  The thing we were doing when we decided to
+ *     see if we had access to FxA
+ */
+function throwIfNoFxA(fxAccounts, action) {
+  if (!fxAccounts) {
+    throw new Error(`${action} is impossible because FXAccounts is not available; are you on Android?`);
+  }
+}
+
+// Global ExtensionStorageSync instance that extensions and Fx Sync use.
+// On Android, because there's no FXAccounts instance, any syncing
+// operations will fail.
+this.extensionStorageSync = null;
+
 /**
  * Utility function to enforce an order of fields when computing an HMAC.
  *
  * @param {KeyBundle} keyBundle  The key bundle to use to compute the HMAC
  * @param {string}    id         The record ID to use when computing the HMAC
  * @param {string}    IV         The IV to use when computing the HMAC
  * @param {string}    ciphertext The ciphertext over which to compute the HMAC
  * @returns {string} The computed HMAC
  */
 function ciphertextHMAC(keyBundle, id, IV, ciphertext) {
   const hasher = keyBundle.sha256HMACHasher;
   return Utils.bytesAsHex(Utils.digestUTF8(id + IV + ciphertext, hasher));
 }
 
 /**
+ * Get the current user's hashed kB.
+ *
+ * @param {FXAccounts} fxaService  The service to use to get the
+ *     current user.
+ * @returns {string} sha256 of the user's kB as a hex string
+ */
+const getKBHash = Task.async(function* (fxaService) {
+  const signedInUser = yield fxaService.getSignedInUser();
+  if (!signedInUser) {
+    throw new Error("User isn't signed in!");
+  }
+
+  if (!signedInUser.kB) {
+    throw new Error("User doesn't have kB??");
+  }
+
+  let kBbytes = CommonUtils.hexToBytes(signedInUser.kB);
+  let hasher = Cc["@mozilla.org/security/hash;1"]
+      .createInstance(Ci.nsICryptoHash);
+  hasher.init(hasher.SHA256);
+  return CommonUtils.bytesAsHex(CryptoUtils.digestBytes(signedInUser.uid + kBbytes, hasher));
+});
+
+/**
  * A "remote transformer" that the Kinto library will use to
  * encrypt/decrypt records when syncing.
  *
  * This is an "abstract base class". Subclass this and override
  * getKeys() to use it.
  */
 class EncryptionRemoteTransformer {
   encode(record) {
@@ -197,28 +249,28 @@ class EncryptionRemoteTransformer {
    * @returns {Promise<string>} The ID to use.
    */
   getEncodedRecordId(record) {
     return Promise.resolve(record.id);
   }
 }
 global.EncryptionRemoteTransformer = EncryptionRemoteTransformer;
 
-// This is meant to be a hook for use during unit testing.
-EncryptionRemoteTransformer.prototype._fxaService = null;
-if (AppConstants.platform != "android") {
-  EncryptionRemoteTransformer.prototype._fxaService = fxAccounts;
-}
-
 /**
  * An EncryptionRemoteTransformer that provides a keybundle derived
  * from the user's kB, suitable for encrypting a keyring.
  */
 class KeyRingEncryptionRemoteTransformer extends EncryptionRemoteTransformer {
+  constructor(fxaService) {
+    super();
+    this._fxaService = fxaService;
+  }
+
   getKeys() {
+    throwIfNoFxA(this._fxaService, "encrypting chrome.storage.sync records");
     const self = this;
     return Task.spawn(function* () {
       const user = yield self._fxaService.getSignedInUser();
       // FIXME: we should permit this if the user is self-hosting
       // their storage
       if (!user) {
         throw new Error("user isn't signed in to FxA; can't sync");
       }
@@ -245,33 +297,30 @@ class KeyRingEncryptionRemoteTransformer
     const encodePromise = super.encode(record);
     return Task.spawn(function* () {
       const encoded = yield encodePromise;
       encoded.kbHash = record.kbHash;
       return encoded;
     });
   }
 
-  decode(record) {
-    const decodePromise = super.decode(record);
-    return Task.spawn(function* () {
-      try {
-        return yield decodePromise;
-      } catch (e) {
-        if (Utils.isHMACMismatch(e)) {
-          const currentKBHash = yield ExtensionStorageSync.getKBHash();
-          if (record.kbHash != currentKBHash) {
-            // Some other client encoded this with a kB that we don't
-            // have access to.
-            KeyRingEncryptionRemoteTransformer.throwOutdatedKB(currentKBHash, record.kbHash);
-          }
+  async decode(record) {
+    try {
+      return await super.decode(record);
+    } catch (e) {
+      if (Utils.isHMACMismatch(e)) {
+        const currentKBHash = await getKBHash(this._fxaService);
+        if (record.kbHash != currentKBHash) {
+          // Some other client encoded this with a kB that we don't
+          // have access to.
+          KeyRingEncryptionRemoteTransformer.throwOutdatedKB(currentKBHash, record.kbHash);
         }
-        throw e;
       }
-    });
+      throw e;
+    }
   }
 
   // Generator and discriminator for KB-is-outdated exceptions.
   static throwOutdatedKB(shouldBe, is) {
     throw new Error(`kB hash on record is outdated: should be ${shouldBe}, is ${is}`);
   }
 
   static isOutdatedKB(exc) {
@@ -379,34 +428,39 @@ const cryptoCollectionIdSchema = {
   validate(id) {
     return true;
   },
 };
 
 /**
  * Wrapper around the crypto collection providing some handy utilities.
  */
-let cryptoCollection = this.cryptoCollection = {
-  getCollection: Task.async(function* () {
-    const {kinto} = yield storageSyncInit;
+class CryptoCollection {
+  constructor(fxaService) {
+    this._fxaService = fxaService;
+  }
+
+  async getCollection() {
+    throwIfNoFxA(this._fxaService, "tried to access cryptoCollection");
+    const {kinto} = await storageSyncInit;
     return kinto.collection(STORAGE_SYNC_CRYPTO_COLLECTION_NAME, {
       idSchema: cryptoCollectionIdSchema,
-      remoteTransformers: [new KeyRingEncryptionRemoteTransformer()],
+      remoteTransformers: [new KeyRingEncryptionRemoteTransformer(this._fxaService)],
     });
-  }),
+  }
 
   /**
    * Generate a new salt for use in hashing extension and record
    * IDs.
    *
    * @returns {string} A base64-encoded string of the salt
    */
   getNewSalt() {
     return btoa(CryptoUtils.generateRandomBytes(STORAGE_SYNC_CRYPTO_SALT_LENGTH_BYTES));
-  },
+  }
 
   /**
    * Retrieve the keyring record from the crypto collection.
    *
    * You can use this if you want to check metadata on the keyring
    * record rather than use the keyring itself.
    *
    * The keyring record, if present, should have the structure:
@@ -416,46 +470,51 @@ let cryptoCollection = this.cryptoCollec
    * - uuid: a record identifier. This will only change when we wipe
    *   the collection (due to kB getting reset).
    * - keys: a "WBO" form of a CollectionKeyManager.
    * - salts: a normal JS Object with keys being collection IDs and
    *   values being base64-encoded salts to use when hashing IDs
    *   for that collection.
    * @returns {Promise<Object>}
    */
-  getKeyRingRecord: Task.async(function* () {
-    const collection = yield this.getCollection();
-    const cryptoKeyRecord = yield collection.getAny(STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID);
+  async getKeyRingRecord() {
+    const collection = await this.getCollection();
+    const cryptoKeyRecord = await collection.getAny(STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID);
 
     let data = cryptoKeyRecord.data;
     if (!data) {
       // This is a new keyring. Invent an ID for this record. If this
       // changes, it means a client replaced the keyring, so we need to
       // reupload everything.
       const uuidgen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
       const uuid = uuidgen.generateUUID().toString();
       data = {uuid, id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID};
     }
     return data;
-  }),
+  }
 
-  getSalts: Task.async(function* () {
-    const cryptoKeyRecord = yield this.getKeyRingRecord();
+  async getSalts() {
+    const cryptoKeyRecord = await this.getKeyRingRecord();
     return cryptoKeyRecord && cryptoKeyRecord.salts;
-  }),
+  }
 
   /**
    * Used for testing with a known salt.
+   *
+   * @param {string} extensionId  The extension ID for which to set a
+   *     salt.
+   * @param {string} salt  The salt to use for this extension, as a
+   *     base64-encoded salt.
    */
-  _setSalt: Task.async(function* (extensionId, salt) {
-    const cryptoKeyRecord = yield this.getKeyRingRecord();
+  async _setSalt(extensionId, salt) {
+    const cryptoKeyRecord = await this.getKeyRingRecord();
     cryptoKeyRecord.salts = cryptoKeyRecord.salts || {};
     cryptoKeyRecord.salts[extensionId] = salt;
-    this.upsert(cryptoKeyRecord);
-  }),
+    await this.upsert(cryptoKeyRecord);
+  }
 
   /**
    * Hash an extension ID for a given user so that an attacker can't
    * identify the extensions a user has installed.
    *
    * The extension ID is assumed to be a string (i.e. series of
    * code points), and its UTF8 encoding is prefixed with the salt
    * for that collection and hashed.
@@ -468,129 +527,131 @@ let cryptoCollection = this.cryptoCollec
    * (-) or an underscore (_), prefix it with "ext-".
    *
    * @param {string} extensionId The extension ID to obfuscate.
    * @returns {Promise<bytestring>} A collection ID suitable for use to sync to.
    */
   extensionIdToCollectionId(extensionId) {
     return this.hashWithExtensionSalt(CommonUtils.encodeUTF8(extensionId), extensionId)
       .then(hash => `ext-${hash}`);
-  },
+  }
 
   /**
    * Hash some value with the salt for the given extension.
    *
    * The value should be a "bytestring", i.e. a string whose
    * "characters" are values, each within [0, 255]. You can produce
    * such a bytestring using e.g. CommonUtils.encodeUTF8.
    *
    * The returned value is a base64url-encoded string of the hash.
    *
    * @param {bytestring} value The value to be hashed.
    * @param {string} extensionId The ID of the extension whose salt
    *                             we should use.
    * @returns {Promise<bytestring>} The hashed value.
    */
-  hashWithExtensionSalt: Task.async(function* (value, extensionId) {
-    const salts = yield this.getSalts();
+  async hashWithExtensionSalt(value, extensionId) {
+    const salts = await this.getSalts();
     const saltBase64 = salts && salts[extensionId];
     if (!saltBase64) {
       // This should never happen; salts should be populated before
       // we need them by ensureCanSync.
       throw new Error(`no salt available for ${extensionId}; how did this happen?`);
     }
 
     const hasher = Cc["@mozilla.org/security/hash;1"]
           .createInstance(Ci.nsICryptoHash);
     hasher.init(hasher.SHA256);
 
     const salt = atob(saltBase64);
     const message = `${salt}\x00${value}`;
     const hash = CryptoUtils.digestBytes(message, hasher);
     return CommonUtils.encodeBase64URL(hash, false);
-  }),
+  }
 
   /**
    * Retrieve the actual keyring from the crypto collection.
    *
    * @returns {Promise<CollectionKeyManager>}
    */
-  getKeyRing: Task.async(function* () {
-    const cryptoKeyRecord = yield this.getKeyRingRecord();
+  async getKeyRing() {
+    const cryptoKeyRecord = await this.getKeyRingRecord();
     const collectionKeys = new CollectionKeyManager();
     if (cryptoKeyRecord.keys) {
       collectionKeys.setContents(cryptoKeyRecord.keys, cryptoKeyRecord.last_modified);
     } else {
       // We never actually use the default key, so it's OK if we
       // generate one multiple times.
       collectionKeys.generateDefaultKey();
     }
     // Pass through uuid field so that we can save it if we need to.
     collectionKeys.uuid = cryptoKeyRecord.uuid;
     return collectionKeys;
-  }),
+  }
 
-  updateKBHash: Task.async(function* (kbHash) {
-    const coll = yield this.getCollection();
-    yield coll.update({id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID,
+  async updateKBHash(kbHash) {
+    const coll = await this.getCollection();
+    await coll.update({id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID,
                        kbHash: kbHash},
                       {patch: true});
-  }),
+  }
 
-  upsert: Task.async(function* (record) {
-    const collection = yield this.getCollection();
-    yield collection.upsert(record);
-  }),
+  async upsert(record) {
+    const collection = await this.getCollection();
+    await collection.upsert(record);
+  }
 
-  sync: Task.async(function* () {
-    const collection = yield this.getCollection();
-    return yield ExtensionStorageSync._syncCollection(collection, {
+  async sync(extensionStorageSync) {
+    const collection = await this.getCollection();
+    return await extensionStorageSync._syncCollection(collection, {
       strategy: "server_wins",
     });
-  }),
+  }
 
   /**
    * Reset sync status for ALL collections by directly
    * accessing the FirefoxAdapter.
    */
-  resetSyncStatus: Task.async(function* () {
-    const coll = yield this.getCollection();
-    yield coll.db.resetSyncStatus();
-  }),
+  async resetSyncStatus() {
+    const coll = await this.getCollection();
+    await coll.db.resetSyncStatus();
+  }
 
   // Used only for testing.
-  _clear: Task.async(function* () {
-    const collection = yield this.getCollection();
-    yield collection.clear();
-  }),
-};
+  async _clear() {
+    const collection = await this.getCollection();
+    await collection.clear();
+  }
+}
+this.CryptoCollection = CryptoCollection;
 
 /**
  * An EncryptionRemoteTransformer for extension records.
  *
  * It uses the special "keys" record to find a key for a given
  * extension, thus its name
  * CollectionKeyEncryptionRemoteTransformer.
  *
  * Also, during encryption, it will replace the ID of the new record
  * with a hashed ID, using the salt for this collection.
  *
  * @param {string} extensionId The extension ID for which to find a key.
    */
 let CollectionKeyEncryptionRemoteTransformer = class extends EncryptionRemoteTransformer {
-  constructor(extensionId) {
+  constructor(cryptoCollection, extensionId) {
     super();
+    this.cryptoCollection = cryptoCollection;
     this.extensionId = extensionId;
   }
 
   getKeys() {
     const self = this;
     return Task.spawn(function* () {
       // FIXME: cache the crypto record for the duration of a sync cycle?
-      const collectionKeys = yield cryptoCollection.getKeyRing();
+      const collectionKeys = yield self.cryptoCollection.getKeyRing();
       if (!collectionKeys.hasKeysFor([self.extensionId])) {
         // This should never happen. Keys should be created (and
         // synced) at the beginning of the sync cycle.
         throw new Error(`tried to encrypt records for ${this.extensionId}, but key is not present`);
       }
       return collectionKeys.keyForCollection(self.extensionId);
     });
   }
@@ -598,17 +659,17 @@ let CollectionKeyEncryptionRemoteTransfo
   getEncodedRecordId(record) {
     // It isn't really clear whether kinto.js record IDs are
     // bytestrings or strings that happen to only contain ASCII
     // characters, so encode them to be sure.
     const id = CommonUtils.encodeUTF8(record.id);
     // Like extensionIdToCollectionId, the rules about Kinto record
     // IDs preclude equals signs or strings starting with a
     // non-alphanumeric, so prefix all IDs with a constant "id-".
-    return cryptoCollection.hashWithExtensionSalt(id, this.extensionId)
+    return this.cryptoCollection.hashWithExtensionSalt(id, this.extensionId)
       .then(hash => `id-${hash}`);
   }
 };
 
 global.CollectionKeyEncryptionRemoteTransformer = CollectionKeyEncryptionRemoteTransformer;
 
 /**
  * Clean up now that one context is no longer using this extension's collection.
@@ -637,68 +698,68 @@ function cleanUpForContext(extension, co
  *                    The extension whose collection needs to
  *                    be opened.
  * @param {Context} context
  *                  The context for this extension. The Collection
  *                  will shut down automatically when all contexts
  *                  close.
  * @returns {Promise<Collection>}
  */
-const openCollection = Task.async(function* (extension, context) {
+const openCollection = Task.async(function* (cryptoCollection, extension, context) {
   let collectionId = extension.id;
   const {kinto} = yield storageSyncInit;
-  const remoteTransformers = [new CollectionKeyEncryptionRemoteTransformer(extension.id)];
+  const remoteTransformers = [new CollectionKeyEncryptionRemoteTransformer(cryptoCollection, extension.id)];
   const coll = kinto.collection(collectionId, {
     idSchema: storageSyncIdSchema,
     remoteTransformers,
   });
   return coll;
 });
 
-// FIXME: This is kind of ugly. Probably we should have
-// ExtensionStorageSync not be a singleton, but a constructed object,
-// and this should be a constructor argument.
-let _fxaService = null;
-if (AppConstants.platform != "android") {
-  _fxaService = fxAccounts;
-}
+class ExtensionStorageSync {
+  /**
+   * @param {FXAccounts} fxaService (Optional) If not
+   *    present, trying to sync will fail.
+   */
+  constructor(fxaService) {
+    this._fxaService = fxaService;
+    this.cryptoCollection = new CryptoCollection(fxaService);
+    this.listeners = new WeakMap();
+  }
 
-this.ExtensionStorageSync = {
-  _fxaService,
-  listeners: new WeakMap(),
-
-  syncAll: Task.async(function* () {
+  async syncAll() {
     const extensions = extensionContexts.keys();
     const extIds = Array.from(extensions, extension => extension.id);
     log.debug(`Syncing extension settings for ${JSON.stringify(extIds)}`);
     if (extIds.length == 0) {
       // No extensions to sync. Get out.
       return;
     }
-    yield this.ensureCanSync(extIds);
-    yield this.checkSyncKeyRing();
+    await this.ensureCanSync(extIds);
+    await this.checkSyncKeyRing();
     const promises = Array.from(extensionContexts.keys(), extension => {
-      return openCollection(extension).then(coll => {
+      return openCollection(this.cryptoCollection, extension).then(coll => {
         return this.sync(extension, coll);
       });
     });
-    yield Promise.all(promises);
-  }),
+    await Promise.all(promises);
+  }
 
-  sync: Task.async(function* (extension, collection) {
-    const signedInUser = yield this._fxaService.getSignedInUser();
+  async sync(extension, collection) {
+    throwIfNoFxA(this._fxaService, "syncing chrome.storage.sync");
+    const signedInUser = await this._fxaService.getSignedInUser();
     if (!signedInUser) {
       // FIXME: this should support syncing to self-hosted
       log.info("User was not signed into FxA; cannot sync");
       throw new Error("Not signed in to FxA");
     }
-    const collectionId = yield cryptoCollection.extensionIdToCollectionId(extension.id);
+    const collectionId = await this.cryptoCollection.extensionIdToCollectionId(extension.id);
     let syncResults;
     try {
-      syncResults = yield this._syncCollection(collection, {
+      syncResults = await this._syncCollection(collection, {
         strategy: "client_wins",
         collection: collectionId,
       });
     } catch (err) {
       log.warn("Syncing failed", err);
       throw err;
     }
 
@@ -730,91 +791,94 @@ this.ExtensionStorageSync = {
       changes[conflict.remote.key] = {
         oldValue: conflict.local.data,
         newValue: conflict.remote.data,
       };
     }
     if (Object.keys(changes).length > 0) {
       this.notifyListeners(extension, changes);
     }
-  }),
+  }
 
   /**
    * Utility function that handles the common stuff about syncing all
    * Kinto collections (including "meta" collections like the crypto
    * one).
    *
    * @param {Collection} collection
    * @param {Object} options
    *                 Additional options to be passed to sync().
    * @returns {Promise<SyncResultObject>}
    */
-  _syncCollection: Task.async(function* (collection, options) {
+  async _syncCollection(collection, options) {
     // FIXME: this should support syncing to self-hosted
-    return yield this._requestWithToken(`Syncing ${collection.name}`, function* (token) {
+    return await this._requestWithToken(`Syncing ${collection.name}`, async function(token) {
       const allOptions = Object.assign({}, {
         remote: prefStorageSyncServerURL,
         headers: {
           Authorization: "Bearer " + token,
         },
       }, options);
 
-      return yield collection.sync(allOptions);
+      return await collection.sync(allOptions);
     });
-  }),
+  }
 
   // Make a Kinto request with a current FxA token.
   // If the response indicates that the token might have expired,
   // retry the request.
-  _requestWithToken: Task.async(function* (description, f) {
-    const fxaToken = yield this._fxaService.getOAuthToken(FXA_OAUTH_OPTIONS);
+  async _requestWithToken(description, f) {
+    throwIfNoFxA(this._fxaService, "making remote requests from chrome.storage.sync");
+    const fxaToken = await this._fxaService.getOAuthToken(FXA_OAUTH_OPTIONS);
     try {
-      return yield f(fxaToken);
+      return await f(fxaToken);
     } catch (e) {
       log.error(`${description}: request failed`, e);
       if (e && e.data && e.data.code == 401) {
         // Our token might have expired. Refresh and retry.
         log.info("Token might have expired");
-        yield this._fxaService.removeCachedOAuthToken({token: fxaToken});
-        const newToken = yield this._fxaService.getOAuthToken(FXA_OAUTH_OPTIONS);
+        await this._fxaService.removeCachedOAuthToken({token: fxaToken});
+        const newToken = await this._fxaService.getOAuthToken(FXA_OAUTH_OPTIONS);
 
         // If this fails too, let it go.
-        return yield f(newToken);
+        return await f(newToken);
       }
       // Otherwise, we don't know how to handle this error, so just reraise.
       throw e;
     }
-  }),
+  }
 
   /**
    * Helper similar to _syncCollection, but for deleting the user's bucket.
+   *
+   * @returns {Promise<void>}
    */
-  _deleteBucket: Task.async(function* () {
-    return yield this._requestWithToken("Clearing server", function* (token) {
+  async _deleteBucket() {
+    return await this._requestWithToken("Clearing server", async function(token) {
       const headers = {Authorization: "Bearer " + token};
       const kintoHttp = new KintoHttpClient(prefStorageSyncServerURL, {
         headers: headers,
         timeout: KINTO_REQUEST_TIMEOUT,
       });
-      return yield kintoHttp.deleteBucket("default");
+      return await kintoHttp.deleteBucket("default");
     });
-  }),
+  }
 
-  ensureSaltsFor: Task.async(function* (keysRecord, extIds) {
+  async ensureSaltsFor(keysRecord, extIds) {
     const newSalts = Object.assign({}, keysRecord.salts);
     for (let collectionId of extIds) {
       if (newSalts[collectionId]) {
         continue;
       }
 
-      newSalts[collectionId] = cryptoCollection.getNewSalt();
+      newSalts[collectionId] = this.cryptoCollection.getNewSalt();
     }
 
     return newSalts;
-  }),
+  }
 
   /**
    * Check whether the keys record (provided) already has salts for
    * all the extensions given in extIds.
    *
    * @param {Object} keysRecord A previously-retrieved keys record.
    * @param {Array<string>} extIds The IDs of the extensions which
    *                need salts.
@@ -827,122 +891,102 @@ this.ExtensionStorageSync = {
 
     for (let collectionId of extIds) {
       if (!keysRecord.salts[collectionId]) {
         return false;
       }
     }
 
     return true;
-  },
+  }
 
   /**
    * Recursive promise that terminates when our local collectionKeys,
    * as well as that on the server, have keys for all the extensions
    * in extIds.
    *
    * @param {Array<string>} extIds
    *                        The IDs of the extensions which need keys.
    * @returns {Promise<CollectionKeyManager>}
    */
-  ensureCanSync: Task.async(function* (extIds) {
-    const keysRecord = yield cryptoCollection.getKeyRingRecord();
-    const collectionKeys = yield cryptoCollection.getKeyRing();
+  async ensureCanSync(extIds) {
+    const keysRecord = await this.cryptoCollection.getKeyRingRecord();
+    const collectionKeys = await this.cryptoCollection.getKeyRing();
     if (collectionKeys.hasKeysFor(extIds) && this.hasSaltsFor(keysRecord, extIds)) {
       return collectionKeys;
     }
 
-    const kbHash = yield this.getKBHash();
-    const newKeys = yield collectionKeys.ensureKeysFor(extIds);
-    const newSalts = yield this.ensureSaltsFor(keysRecord, extIds);
+    const kbHash = await getKBHash(this._fxaService);
+    const newKeys = await collectionKeys.ensureKeysFor(extIds);
+    const newSalts = await this.ensureSaltsFor(keysRecord, extIds);
     const newRecord = {
       id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID,
       keys: newKeys.asWBO().cleartext,
       salts: newSalts,
       uuid: collectionKeys.uuid,
       // Add a field for the current kB hash.
       kbHash: kbHash,
     };
-    yield cryptoCollection.upsert(newRecord);
-    const result = yield this._syncKeyRing(newRecord);
+    await this.cryptoCollection.upsert(newRecord);
+    const result = await this._syncKeyRing(newRecord);
     if (result.resolved.length != 0) {
       // We had a conflict which was automatically resolved. We now
       // have a new keyring which might have keys for the
       // collections. Recurse.
-      return yield this.ensureCanSync(extIds);
+      return await this.ensureCanSync(extIds);
     }
 
     // No conflicts. We're good.
     return newKeys;
-  }),
-
-  /**
-   * Get the current user's hashed kB.
-   *
-   * @returns sha256 of the user's kB as a hex string
-   */
-  getKBHash: Task.async(function* () {
-    const signedInUser = yield this._fxaService.getSignedInUser();
-    if (!signedInUser) {
-      throw new Error("User isn't signed in!");
-    }
-
-    if (!signedInUser.kB) {
-      throw new Error("User doesn't have kB??");
-    }
-
-    let kBbytes = CommonUtils.hexToBytes(signedInUser.kB);
-    let hasher = Cc["@mozilla.org/security/hash;1"]
-                    .createInstance(Ci.nsICryptoHash);
-    hasher.init(hasher.SHA256);
-    return CommonUtils.bytesAsHex(CryptoUtils.digestBytes(signedInUser.uid + kBbytes, hasher));
-  }),
+  }
 
   /**
    * Update the kB in the crypto record.
    */
-  updateKeyRingKB: Task.async(function* () {
-    const signedInUser = yield this._fxaService.getSignedInUser();
+  async updateKeyRingKB() {
+    throwIfNoFxA(this._fxaService, "use of chrome.storage.sync \"keyring\"");
+    const signedInUser = await this._fxaService.getSignedInUser();
     if (!signedInUser) {
       // Although this function is meant to be called on login,
       // it's not unreasonable to check any time, even if we aren't
       // logged in.
       //
       // If we aren't logged in, we don't have any information about
       // the user's kB, so we can't be sure that the user changed
       // their kB, so just return.
       return;
     }
 
-    const thisKBHash = yield this.getKBHash();
-    yield cryptoCollection.updateKBHash(thisKBHash);
-  }),
+    const thisKBHash = await getKBHash(this._fxaService);
+    await this.cryptoCollection.updateKBHash(thisKBHash);
+  }
 
   /**
    * Make sure the keyring is up to date and synced.
    *
    * This is called on syncs to make sure that we don't sync anything
    * to any collection unless the key for that collection is on the
    * server.
    */
-  checkSyncKeyRing: Task.async(function* () {
-    yield this.updateKeyRingKB();
+  async checkSyncKeyRing() {
+    await this.updateKeyRingKB();
 
-    const cryptoKeyRecord = yield cryptoCollection.getKeyRingRecord();
+    const cryptoKeyRecord = await this.cryptoCollection.getKeyRingRecord();
     if (cryptoKeyRecord && cryptoKeyRecord._status !== "synced") {
       // We haven't successfully synced the keyring since the last
       // change. This could be because kB changed and we touched the
       // keyring, or it could be because we failed to sync after
       // adding a key. Either way, take this opportunity to sync the
       // keyring.
-      yield this._syncKeyRing(cryptoKeyRecord);
+      await this._syncKeyRing(cryptoKeyRecord);
     }
-  }),
+  }
 
-  _syncKeyRing: Task.async(function* (cryptoKeyRecord) {
+  async _syncKeyRing(cryptoKeyRecord) {
+    throwIfNoFxA(this._fxaService, "syncing chrome.storage.sync \"keyring\"");
     try {
       // Try to sync using server_wins.
       //
       // We use server_wins here because whatever is on the server is
       // at least consistent with itself -- the crypto in the keyring
       // matches the crypto on the collection records. This is because
       // we generate and upload keys just before syncing data.
       //
@@ -960,46 +1004,46 @@ this.ExtensionStorageSync = {
       // everything we have on our end too, so we detect this by
       // adding a UUID to the keyring. UUIDs are preserved throughout
       // the lifetime of a keyring, so the only time a keyring UUID
       // changes is when a new keyring is uploaded, which only happens
       // after a server wipe. So when we get a "conflict" (resolved by
       // server_wins), we check whether the server version has a new
       // UUID. If so, reset our sync status, so that we'll reupload
       // everything.
-      const result = yield cryptoCollection.sync();
+      const result = await this.cryptoCollection.sync(this);
       if (result.resolved.length > 0) {
         if (result.resolved[0].uuid != cryptoKeyRecord.uuid) {
           log.info(`Detected a new UUID (${result.resolved[0].uuid}, was ${cryptoKeyRecord.uuid}). Reseting sync status for everything.`);
-          yield cryptoCollection.resetSyncStatus();
+          await this.cryptoCollection.resetSyncStatus();
 
           // Server version is now correct. Return that result.
           return result;
         }
       }
       // No conflicts, or conflict was just someone else adding keys.
       return result;
     } catch (e) {
       if (KeyRingEncryptionRemoteTransformer.isOutdatedKB(e)) {
         // Check if our token is still valid, or if we got locked out
         // between starting the sync and talking to Kinto.
-        const isSessionValid = yield this._fxaService.sessionStatus();
+        const isSessionValid = await this._fxaService.sessionStatus();
         if (isSessionValid) {
-          yield this._deleteBucket();
-          yield cryptoCollection.resetSyncStatus();
+          await this._deleteBucket();
+          await this.cryptoCollection.resetSyncStatus();
 
           // Reupload our keyring, which is the only new keyring.
           // We don't want client_wins here because another device
           // could have uploaded another keyring in the meantime.
-          return yield cryptoCollection.sync();
+          return await this.cryptoCollection.sync(this);
         }
       }
       throw e;
     }
-  }),
+  }
 
   /**
    * Get the collection for an extension, and register the extension
    * as being "in use".
    *
    * @param {Extension} extension
    *                    The extension for which we are seeking
    *                    a collection.
@@ -1021,24 +1065,24 @@ this.ExtensionStorageSync = {
       // New context. Register it and make sure it cleans itself up
       // when it closes.
       contexts.add(context);
       context.callOnClose({
         close: () => cleanUpForContext(extension, context),
       });
     }
 
-    return openCollection(extension, context);
-  },
+    return openCollection(this.cryptoCollection, extension, context);
+  }
 
-  set: Task.async(function* (extension, items, context) {
-    const coll = yield this.getCollection(extension, context);
+  async set(extension, items, context) {
+    const coll = await this.getCollection(extension, context);
     const keys = Object.keys(items);
     const ids = keys.map(keyToId);
-    const changes = yield coll.execute(txn => {
+    const changes = await coll.execute(txn => {
       let changes = {};
       for (let [i, key] of keys.entries()) {
         const id = ids[i];
         let item = items[key];
         let {oldRecord} = txn.upsert({
           id,
           key,
           data: item,
@@ -1050,57 +1094,57 @@ this.ExtensionStorageSync = {
           // Extract the "data" field from the old record, which
           // represents the value part of the key-value store
           changes[key].oldValue = oldRecord.data;
         }
       }
       return changes;
     }, {preloadIds: ids});
     this.notifyListeners(extension, changes);
-  }),
+  }
 
-  remove: Task.async(function* (extension, keys, context) {
-    const coll = yield this.getCollection(extension, context);
+  async remove(extension, keys, context) {
+    const coll = await this.getCollection(extension, context);
     keys = [].concat(keys);
     const ids = keys.map(keyToId);
     let changes = {};
-    yield coll.execute(txn => {
+    await coll.execute(txn => {
       for (let [i, key] of keys.entries()) {
         const id = ids[i];
         const res = txn.deleteAny(id);
         if (res.deleted) {
           changes[key] = {
             oldValue: res.data.data,
           };
         }
       }
       return changes;
     }, {preloadIds: ids});
     if (Object.keys(changes).length > 0) {
       this.notifyListeners(extension, changes);
     }
-  }),
+  }
 
-  clear: Task.async(function* (extension, context) {
+  async clear(extension, context) {
     // We can't call Collection#clear here, because that just clears
     // the local database. We have to explicitly delete everything so
     // that the deletions can be synced as well.
-    const coll = yield this.getCollection(extension, context);
-    const res = yield coll.list();
+    const coll = await this.getCollection(extension, context);
+    const res = await coll.list();
     const records = res.data;
     const keys = records.map(record => record.key);
-    yield this.remove(extension, keys, context);
-  }),
+    await this.remove(extension, keys, context);
+  }
 
-  get: Task.async(function* (extension, spec, context) {
-    const coll = yield this.getCollection(extension, context);
+  async get(extension, spec, context) {
+    const coll = await this.getCollection(extension, context);
     let keys, records;
     if (spec === null) {
       records = {};
-      const res = yield coll.list();
+      const res = await coll.list();
       for (let record of res.data) {
         records[record.key] = record.data;
       }
       return records;
     }
     if (typeof spec === "string") {
       keys = [spec];
       records = {};
@@ -1108,44 +1152,46 @@ this.ExtensionStorageSync = {
       keys = spec;
       records = {};
     } else {
       keys = Object.keys(spec);
       records = Cu.cloneInto(spec, global);
     }
 
     for (let key of keys) {
-      const res = yield coll.getAny(keyToId(key));
+      const res = await coll.getAny(keyToId(key));
       if (res.data && res.data._status != "deleted") {
         records[res.data.key] = res.data.data;
       }
     }
 
     return records;
-  }),
+  }
 
   addOnChangedListener(extension, listener, context) {
     let listeners = this.listeners.get(extension) || new Set();
     listeners.add(listener);
     this.listeners.set(extension, listeners);
 
     // Force opening the collection so that we will sync for this extension.
     return this.getCollection(extension, context);
-  },
+  }
 
   removeOnChangedListener(extension, listener) {
     let listeners = this.listeners.get(extension);
     listeners.delete(listener);
     if (listeners.size == 0) {
       this.listeners.delete(extension);
     }
-  },
+  }
 
   notifyListeners(extension, changes) {
     Observers.notify("ext.storage.sync-changed");
     let listeners = this.listeners.get(extension) || new Set();
     if (listeners) {
       for (let listener of listeners) {
         runSafeSyncWithoutClone(listener, changes);
       }
     }
-  },
-};
+  }
+}
+this.ExtensionStorageSync = ExtensionStorageSync;
+this.extensionStorageSync = new ExtensionStorageSync(_fxaService);
--- a/toolkit/components/extensions/ext-storage.js
+++ b/toolkit/components/extensions/ext-storage.js
@@ -1,15 +1,15 @@
 "use strict";
 
 var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage",
                                   "resource://gre/modules/ExtensionStorage.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorageSync",
+XPCOMUtils.defineLazyModuleGetter(this, "extensionStorageSync",
                                   "resource://gre/modules/ExtensionStorageSync.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManagerPrivate",
                                   "resource://gre/modules/AddonManager.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   ExtensionError,
   SingletonEventManager,
@@ -42,45 +42,45 @@ function storageApiFactory(context) {
         clear: function() {
           return ExtensionStorage.clear(extension.id);
         },
       },
 
       sync: {
         get: function(spec) {
           enforceNoTemporaryAddon(extension.id);
-          return ExtensionStorageSync.get(extension, spec, context);
+          return extensionStorageSync.get(extension, spec, context);
         },
         set: function(items) {
           enforceNoTemporaryAddon(extension.id);
-          return ExtensionStorageSync.set(extension, items, context);
+          return extensionStorageSync.set(extension, items, context);
         },
         remove: function(keys) {
           enforceNoTemporaryAddon(extension.id);
-          return ExtensionStorageSync.remove(extension, keys, context);
+          return extensionStorageSync.remove(extension, keys, context);
         },
         clear: function() {
           enforceNoTemporaryAddon(extension.id);
-          return ExtensionStorageSync.clear(extension, context);
+          return extensionStorageSync.clear(extension, context);
         },
       },
 
       onChanged: new SingletonEventManager(context, "storage.onChanged", fire => {
         let listenerLocal = changes => {
           fire.async(changes, "local");
         };
         let listenerSync = changes => {
           fire.async(changes, "sync");
         };
 
         ExtensionStorage.addOnChangedListener(extension.id, listenerLocal);
-        ExtensionStorageSync.addOnChangedListener(extension, listenerSync, context);
+        extensionStorageSync.addOnChangedListener(extension, listenerSync, context);
         return () => {
           ExtensionStorage.removeOnChangedListener(extension.id, listenerLocal);
-          ExtensionStorageSync.removeOnChangedListener(extension, listenerSync);
+          extensionStorageSync.removeOnChangedListener(extension, listenerSync);
         };
       }).api(),
     },
   };
 }
 extensions.registerSchemaAPI("storage", "addon_parent", storageApiFactory);
 extensions.registerSchemaAPI("storage", "content_parent", storageApiFactory);
 extensions.registerSchemaAPI("storage", "devtools_parent", storageApiFactory);
--- a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js
@@ -5,18 +5,17 @@
 
 do_get_profile();   // so we can use FxAccounts
 
 Cu.import("resource://testing-common/httpd.js");
 Cu.import("resource://services-common/utils.js");
 Cu.import("resource://services-crypto/utils.js");
 const {
   CollectionKeyEncryptionRemoteTransformer,
-  cryptoCollection,
-  EncryptionRemoteTransformer,
+  CryptoCollection,
   ExtensionStorageSync,
   idToKey,
   KeyRingEncryptionRemoteTransformer,
   keyToId,
 } = Cu.import("resource://gre/modules/ExtensionStorageSync.jsm", {});
 Cu.import("resource://services-sync/engines/extension-storage.js");
 Cu.import("resource://services-sync/keys.js");
 Cu.import("resource://services-sync/util.js");
@@ -258,27 +257,27 @@ class KintoServer {
           last_modified: 1475161309026,
           id: "b09f1618-d789-302d-696e-74ec53ee18a8", // FIXME
         },
       }));
     });
   }
 
   // Utility function to install a keyring at the start of a test.
-  installKeyRing(keysData, salts, etag, {conflict = false} = {}) {
+  installKeyRing(fxaService, keysData, salts, etag, {conflict = false} = {}) {
     this.installCollection("storage-sync-crypto");
     const keysRecord = {
       "id": "keys",
       "keys": keysData,
       "salts": salts,
       "last_modified": etag,
     };
     this.etag = etag;
     const methodName = conflict ? "encryptAndAddRecordWithConflict" : "encryptAndAddRecord";
-    this[methodName](new KeyRingEncryptionRemoteTransformer(),
+    this[methodName](new KeyRingEncryptionRemoteTransformer(fxaService),
                      "storage-sync-crypto", keysRecord);
   }
 
   // Add an already-encrypted record.
   addRecord(collectionId, record) {
     this.collections.get(collectionId).add(record);
   }
 
@@ -352,37 +351,32 @@ function* withContextAndServer(f) {
   yield* withSyncContext(function* (context) {
     yield* withServer(function* (server) {
       yield* f(context, server);
     });
   });
 }
 
 // Run a block of code with fxa mocked out to return a specific user.
+// Calls the given function with an ExtensionStorageSync instance that
+// was constructed using a mocked FxAccounts instance.
 function* withSignedInUser(user, f) {
-  const oldESSFxAccounts = ExtensionStorageSync._fxaService;
-  const oldERTFxAccounts = EncryptionRemoteTransformer.prototype._fxaService;
-  ExtensionStorageSync._fxaService = EncryptionRemoteTransformer.prototype._fxaService = {
+  let fxaServiceMock = {
     getSignedInUser() {
       return Promise.resolve(user);
     },
     getOAuthToken() {
       return Promise.resolve("some-access-token");
     },
     sessionStatus() {
       return Promise.resolve(true);
     },
   };
-
-  try {
-    yield* f();
-  } finally {
-    ExtensionStorageSync._fxaService = oldESSFxAccounts;
-    EncryptionRemoteTransformer.prototype._fxaService = oldERTFxAccounts;
-  }
+  let extensionStorageSync = new ExtensionStorageSync(fxaServiceMock);
+  yield* f(extensionStorageSync, fxaServiceMock);
 }
 
 // Some assertions that make it easier to write tests about what was
 // posted and when.
 
 // Assert that the request was made with the correct access token.
 // This should be true of all requests, so this is usually called from
 // another assertion.
@@ -405,20 +399,20 @@ function assertPostedNewRecord(post) {
 // Also calls assertAuthenticatedRequest(post).
 function assertPostedUpdatedRecord(post, since) {
   assertAuthenticatedRequest(post);
   equal(post.headers["If-Match"], `"${since}"`);
 }
 
 // Assert that this post was an encrypted keyring, and produce the
 // decrypted body. Sanity check the body while we're here.
-const assertPostedEncryptedKeys = Task.async(function* (post) {
+const assertPostedEncryptedKeys = Task.async(function* (fxaService, post) {
   equal(post.path, collectionRecordsPath("storage-sync-crypto") + "/keys");
 
-  let body = yield new KeyRingEncryptionRemoteTransformer().decode(post.body.data);
+  let body = yield new KeyRingEncryptionRemoteTransformer(fxaService).decode(post.body.data);
   ok(body.keys, `keys object should be present in decoded body`);
   ok(body.keys.default, `keys object should have a default key`);
   ok(body.salts, `salts object should be present in decoded body`);
   return body;
 });
 
 // assertEqual, but for keyring[extensionId] == key.
 function assertKeyRingKey(keyRing, extensionId, expectedKey, message) {
@@ -426,16 +420,31 @@ function assertKeyRingKey(keyRing, exten
     message = `expected keyring's key for ${extensionId} to match ${expectedKey.keyPairB64}`;
   }
   ok(keyRing.hasKeysFor([extensionId]),
      `expected keyring to have a key for ${extensionId}\n`);
   deepEqual(keyRing.keyForCollection(extensionId).keyPairB64, expectedKey.keyPairB64,
             message);
 }
 
+// Assert that this post was posted for a given extension.
+const assertExtensionRecord = Task.async(function* (fxaService, post, extension, key) {
+  const extensionId = extension.id;
+  const cryptoCollection = new CryptoCollection(fxaService);
+  const hashedId = "id-" + (yield cryptoCollection.hashWithExtensionSalt(keyToId(key), extensionId));
+  const collectionId = yield cryptoCollection.extensionIdToCollectionId(extensionId);
+  const transformer = new CollectionKeyEncryptionRemoteTransformer(cryptoCollection, extensionId);
+  equal(post.path, `${collectionRecordsPath(collectionId)}/${hashedId}`,
+        "decrypted data should be posted to path corresponding to its key");
+  let decoded = yield transformer.decode(post.body.data);
+  equal(decoded.key, key,
+        "decrypted data should have a key attribute corresponding to the extension data key");
+  return decoded;
+});
+
 // Tests using this ID will share keys in local storage, so be careful.
 const defaultExtensionId = "{13bdde76-4dc7-11e6-9bdc-54ee758d6342}";
 const defaultExtension = {id: defaultExtensionId};
 
 const BORING_KB = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
 const loggedInUser = {
   uid: "0123456789abcdef0123456789abcdef",
   kB: BORING_KB,
@@ -473,59 +482,65 @@ add_task(function* test_key_to_id() {
   equal(idToKey("key-_HI_"), null);
   equal(idToKey("key-"), "");
   equal(idToKey("key-1"), "1");
   equal(idToKey("key-_2D_"), "-");
 });
 
 add_task(function* test_extension_id_to_collection_id() {
   const extensionId = "{9419cce6-5435-11e6-84bf-54ee758d6342}";
-  // Fake a static keyring since the server doesn't exist.
-  const salt = "Scgx8RJ8Y0rxMGFYArUiKeawlW+0zJyFmtTDvro9qPo=";
-  yield cryptoCollection._setSalt(extensionId, salt);
+  // FIXME: this doesn't actually require the signed in user, but the
+  // extensionIdToCollectionId method exists on CryptoCollection,
+  // which needs an fxaService to be instantiated.
+  yield* withSignedInUser(loggedInUser, function* (extensionStorageSync, fxaService) {
+    // Fake a static keyring since the server doesn't exist.
+    const salt = "Scgx8RJ8Y0rxMGFYArUiKeawlW+0zJyFmtTDvro9qPo=";
+    const cryptoCollection = new CryptoCollection(fxaService);
+    yield cryptoCollection._setSalt(extensionId, salt);
 
-  equal(yield cryptoCollection.extensionIdToCollectionId(extensionId),
-        "ext-0_QHA1P93_yJoj7ONisrR0lW6uN4PZ3Ii-rT-QOjtvo");
+    equal(yield cryptoCollection.extensionIdToCollectionId(extensionId),
+          "ext-0_QHA1P93_yJoj7ONisrR0lW6uN4PZ3Ii-rT-QOjtvo");
+  });
 });
 
 add_task(function* ensureCanSync_posts_new_keys() {
   const extensionId = uuid();
   yield* withContextAndServer(function* (context, server) {
-    yield* withSignedInUser(loggedInUser, function* () {
+    yield* withSignedInUser(loggedInUser, function* (extensionStorageSync, fxaService) {
       server.installCollection("storage-sync-crypto");
       server.etag = 1000;
 
-      let newKeys = yield ExtensionStorageSync.ensureCanSync([extensionId]);
+      let newKeys = yield extensionStorageSync.ensureCanSync([extensionId]);
       ok(newKeys.hasKeysFor([extensionId]), `key isn't present for ${extensionId}`);
 
       let posts = server.getPosts();
       equal(posts.length, 1);
       const post = posts[0];
       assertPostedNewRecord(post);
-      const body = yield assertPostedEncryptedKeys(post);
+      const body = yield assertPostedEncryptedKeys(fxaService, post);
       const oldSalt = body.salts[extensionId];
       ok(body.keys.collections[extensionId], `keys object should have a key for ${extensionId}`);
       ok(oldSalt, `salts object should have a salt for ${extensionId}`);
 
       // Try adding another key to make sure that the first post was
       // OK, even on a new profile.
-      yield cryptoCollection._clear();
+      yield extensionStorageSync.cryptoCollection._clear();
       server.clearPosts();
       // Restore the first posted keyring, but add a last_modified date
       const firstPostedKeyring = Object.assign({}, post.body.data, {last_modified: server.etag});
       server.addRecordInPast("storage-sync-crypto", firstPostedKeyring);
       const extensionId2 = uuid();
-      newKeys = yield ExtensionStorageSync.ensureCanSync([extensionId2]);
+      newKeys = yield extensionStorageSync.ensureCanSync([extensionId2]);
       ok(newKeys.hasKeysFor([extensionId]), `didn't forget key for ${extensionId}`);
       ok(newKeys.hasKeysFor([extensionId2]), `new key generated for ${extensionId2}`);
 
       posts = server.getPosts();
       equal(posts.length, 1);
       const newPost = posts[posts.length - 1];
-      const newBody = yield assertPostedEncryptedKeys(newPost);
+      const newBody = yield assertPostedEncryptedKeys(fxaService, newPost);
       ok(newBody.keys.collections[extensionId], `keys object should have a key for ${extensionId}`);
       ok(newBody.keys.collections[extensionId2], `keys object should have a key for ${extensionId2}`);
       ok(newBody.salts[extensionId], `salts object should have a key for ${extensionId}`);
       ok(newBody.salts[extensionId2], `salts object should have a key for ${extensionId2}`);
       equal(oldSalt, newBody.salts[extensionId], `old salt should be preserved in post`);
     });
   });
 });
@@ -540,90 +555,92 @@ add_task(function* ensureCanSync_pulls_k
   const extensionId = uuid();
   const extensionId2 = uuid();
   const extensionOnlyKey = uuid();
   const extensionOnlySalt = uuid();
   const DEFAULT_KEY = new BulkKeyBundle("[default]");
   DEFAULT_KEY.generateRandom();
   const RANDOM_KEY = new BulkKeyBundle(extensionId);
   RANDOM_KEY.generateRandom();
-  const RANDOM_SALT = cryptoCollection.getNewSalt();
   yield* withContextAndServer(function* (context, server) {
-    yield* withSignedInUser(loggedInUser, function* () {
+    yield* withSignedInUser(loggedInUser, function* (extensionStorageSync, fxaService) {
+      // FIXME: generating a random salt probably shouldn't require a CryptoCollection?
+      const cryptoCollection = new CryptoCollection(fxaService);
+      const RANDOM_SALT = cryptoCollection.getNewSalt();
       const keysData = {
         "default": DEFAULT_KEY.keyPairB64,
         "collections": {
           [extensionId]: RANDOM_KEY.keyPairB64,
         },
       };
       const saltData = {
         [extensionId]: RANDOM_SALT,
       };
-      server.installKeyRing(keysData, saltData, 999);
+      server.installKeyRing(fxaService, keysData, saltData, 999);
 
-      let collectionKeys = yield ExtensionStorageSync.ensureCanSync([extensionId]);
+      let collectionKeys = yield extensionStorageSync.ensureCanSync([extensionId]);
       assertKeyRingKey(collectionKeys, extensionId, RANDOM_KEY);
 
       let posts = server.getPosts();
       equal(posts.length, 0,
             "ensureCanSync shouldn't push when the server keyring has the right key");
 
       // Another client generates a key for extensionId2
       const newKey = new BulkKeyBundle(extensionId2);
       newKey.generateRandom();
       keysData.collections[extensionId2] = newKey.keyPairB64;
       saltData[extensionId2] = cryptoCollection.getNewSalt();
       server.clearCollection("storage-sync-crypto");
-      server.installKeyRing(keysData, saltData, 1000);
+      server.installKeyRing(fxaService, keysData, saltData, 1000);
 
-      let newCollectionKeys = yield ExtensionStorageSync.ensureCanSync([extensionId, extensionId2]);
+      let newCollectionKeys = yield extensionStorageSync.ensureCanSync([extensionId, extensionId2]);
       assertKeyRingKey(newCollectionKeys, extensionId2, newKey);
       assertKeyRingKey(newCollectionKeys, extensionId, RANDOM_KEY,
                        `ensureCanSync shouldn't lose the old key for ${extensionId}`);
 
       posts = server.getPosts();
       equal(posts.length, 0, "ensureCanSync shouldn't push when updating keys");
 
       // Another client generates a key, but not a salt, for extensionOnlyKey
       const onlyKey = new BulkKeyBundle(extensionOnlyKey);
       onlyKey.generateRandom();
       keysData.collections[extensionOnlyKey] = onlyKey.keyPairB64;
       server.clearCollection("storage-sync-crypto");
-      server.installKeyRing(keysData, saltData, 1001);
+      server.installKeyRing(fxaService, keysData, saltData, 1001);
 
-      let withNewKey = yield ExtensionStorageSync.ensureCanSync([extensionId, extensionOnlyKey]);
+      let withNewKey = yield extensionStorageSync.ensureCanSync([extensionId, extensionOnlyKey]);
       dump(`got ${JSON.stringify(withNewKey.asWBO().cleartext)}\n`);
       assertKeyRingKey(withNewKey, extensionOnlyKey, onlyKey);
       assertKeyRingKey(withNewKey, extensionId, RANDOM_KEY,
                        `ensureCanSync shouldn't lose the old key for ${extensionId}`);
 
       posts = server.getPosts();
       equal(posts.length, 1, "ensureCanSync should push when generating a new salt");
-      const withNewKeyRecord = yield assertPostedEncryptedKeys(posts[0]);
+      const withNewKeyRecord = yield assertPostedEncryptedKeys(fxaService, posts[0]);
       // We don't a priori know what the new salt is
       dump(`${JSON.stringify(withNewKeyRecord)}\n`);
       ok(withNewKeyRecord.salts[extensionOnlyKey],
          `ensureCanSync should generate a salt for an extension that only had a key`);
 
       // Another client generates a key, but not a salt, for extensionOnlyKey
       const newSalt = cryptoCollection.getNewSalt();
       saltData[extensionOnlySalt] = newSalt;
       server.clearCollection("storage-sync-crypto");
-      server.installKeyRing(keysData, saltData, 1002);
+      server.installKeyRing(fxaService, keysData, saltData, 1002);
 
-      let withOnlySaltKey = yield ExtensionStorageSync.ensureCanSync([extensionId, extensionOnlySalt]);
+      let withOnlySaltKey = yield extensionStorageSync.ensureCanSync([extensionId, extensionOnlySalt]);
       assertKeyRingKey(withOnlySaltKey, extensionId, RANDOM_KEY,
                        `ensureCanSync shouldn't lose the old key for ${extensionId}`);
       // We don't a priori know what the new key is
       ok(withOnlySaltKey.hasKeysFor([extensionOnlySalt]),
          `ensureCanSync generated a key for an extension that only had a salt`);
 
       posts = server.getPosts();
       equal(posts.length, 2, "ensureCanSync should push when generating a new key");
-      const withNewSaltRecord = yield assertPostedEncryptedKeys(posts[1]);
+      const withNewSaltRecord = yield assertPostedEncryptedKeys(fxaService, posts[1]);
       equal(withNewSaltRecord.salts[extensionOnlySalt], newSalt,
             "ensureCanSync should keep the existing salt when generating only a key");
     });
   });
 });
 
 add_task(function* ensureCanSync_handles_conflicts() {
   // Syncing is done through a pull followed by a push of any merged
@@ -631,146 +648,148 @@ add_task(function* ensureCanSync_handles
   // i.e. with the server rejecting a change -- is if
   // someone pushes changes between our pull and our push. Ensure that
   // if this happens, we still behave sensibly (keep the remote key).
   const extensionId = uuid();
   const DEFAULT_KEY = new BulkKeyBundle("[default]");
   DEFAULT_KEY.generateRandom();
   const RANDOM_KEY = new BulkKeyBundle(extensionId);
   RANDOM_KEY.generateRandom();
-  const RANDOM_SALT = cryptoCollection.getNewSalt();
   yield* withContextAndServer(function* (context, server) {
-    yield* withSignedInUser(loggedInUser, function* () {
+    yield* withSignedInUser(loggedInUser, function* (extensionStorageSync, fxaService) {
+      // FIXME: generating salts probably shouldn't rely on a CryptoCollection
+      const cryptoCollection = new CryptoCollection(fxaService);
+      const RANDOM_SALT = cryptoCollection.getNewSalt();
       const keysData = {
         "default": DEFAULT_KEY.keyPairB64,
         "collections": {
           [extensionId]: RANDOM_KEY.keyPairB64,
         },
       };
       const saltData = {
         [extensionId]: RANDOM_SALT,
       };
-      server.installKeyRing(keysData, saltData, 765, {conflict: true});
+      server.installKeyRing(fxaService, keysData, saltData, 765, {conflict: true});
 
-      yield cryptoCollection._clear();
+      yield extensionStorageSync.cryptoCollection._clear();
 
-      let collectionKeys = yield ExtensionStorageSync.ensureCanSync([extensionId]);
+      let collectionKeys = yield extensionStorageSync.ensureCanSync([extensionId]);
       assertKeyRingKey(collectionKeys, extensionId, RANDOM_KEY,
                        `syncing keyring should keep the server key for ${extensionId}`);
 
       let posts = server.getPosts();
       equal(posts.length, 1,
             "syncing keyring should have tried to post a keyring");
       const failedPost = posts[0];
       assertPostedNewRecord(failedPost);
-      let body = yield assertPostedEncryptedKeys(failedPost);
+      let body = yield assertPostedEncryptedKeys(fxaService, failedPost);
       // This key will be the one the client generated locally, so
       // we don't know what its value will be
       ok(body.keys.collections[extensionId],
          `decrypted failed post should have a key for ${extensionId}`);
       notEqual(body.keys.collections[extensionId], RANDOM_KEY.keyPairB64,
                `decrypted failed post should have a randomly-generated key for ${extensionId}`);
     });
   });
 });
 
 add_task(function* checkSyncKeyRing_reuploads_keys() {
   // Verify that when keys are present, they are reuploaded with the
   // new kB when we call touchKeys().
   const extensionId = uuid();
   let extensionKey, extensionSalt;
   yield* withContextAndServer(function* (context, server) {
-    yield* withSignedInUser(loggedInUser, function* () {
+    yield* withSignedInUser(loggedInUser, function* (extensionStorageSync, fxaService) {
       server.installCollection("storage-sync-crypto");
       server.etag = 765;
 
-      yield cryptoCollection._clear();
+      yield extensionStorageSync.cryptoCollection._clear();
 
       // Do an `ensureCanSync` to generate some keys.
-      let collectionKeys = yield ExtensionStorageSync.ensureCanSync([extensionId]);
+      let collectionKeys = yield extensionStorageSync.ensureCanSync([extensionId]);
       ok(collectionKeys.hasKeysFor([extensionId]),
          `ensureCanSync should return a keyring that has a key for ${extensionId}`);
       extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64;
       equal(server.getPosts().length, 1,
             "generating a key that doesn't exist on the server should post it");
-      const body = yield assertPostedEncryptedKeys(server.getPosts()[0]);
+      const body = yield assertPostedEncryptedKeys(fxaService, server.getPosts()[0]);
       extensionSalt = body.salts[extensionId];
     });
 
     // The user changes their password. This is their new kB, with
     // the last f changed to an e.
     const NOVEL_KB = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdee";
     const newUser = Object.assign({}, loggedInUser, {kB: NOVEL_KB});
     let postedKeys;
-    yield* withSignedInUser(newUser, function* () {
-      yield ExtensionStorageSync.checkSyncKeyRing();
+    yield* withSignedInUser(newUser, function* (extensionStorageSync, fxaService) {
+      yield extensionStorageSync.checkSyncKeyRing();
 
       let posts = server.getPosts();
       equal(posts.length, 2,
             "when kB changes, checkSyncKeyRing should post the keyring reencrypted with the new kB");
       postedKeys = posts[1];
       assertPostedUpdatedRecord(postedKeys, 765);
 
-      let body = yield assertPostedEncryptedKeys(postedKeys);
+      let body = yield assertPostedEncryptedKeys(fxaService, postedKeys);
       deepEqual(body.keys.collections[extensionId], extensionKey,
                 `the posted keyring should have the same key for ${extensionId} as the old one`);
       deepEqual(body.salts[extensionId], extensionSalt,
                 `the posted keyring should have the same salt for ${extensionId} as the old one`);
     });
 
     // Verify that with the old kB, we can't decrypt the record.
-    yield* withSignedInUser(loggedInUser, function* () {
+    yield* withSignedInUser(loggedInUser, function* (extensionStorageSync, fxaService) {
       let error;
       try {
-        yield new KeyRingEncryptionRemoteTransformer().decode(postedKeys.body.data);
+        yield new KeyRingEncryptionRemoteTransformer(fxaService).decode(postedKeys.body.data);
       } catch (e) {
         error = e;
       }
       ok(error, "decrypting the keyring with the old kB should fail");
       ok(Utils.isHMACMismatch(error) || KeyRingEncryptionRemoteTransformer.isOutdatedKB(error),
          "decrypting the keyring with the old kB should throw an HMAC mismatch");
     });
   });
 });
 
 add_task(function* checkSyncKeyRing_overwrites_on_conflict() {
   // If there is already a record on the server that was encrypted
   // with a different kB, we wipe the server, clear sync state, and
   // overwrite it with our keys.
   const extensionId = uuid();
-  const transformer = new KeyRingEncryptionRemoteTransformer();
   let extensionKey;
   yield* withSyncContext(function* (context) {
     yield* withServer(function* (server) {
       // The old device has this kB, which is very similar to the
       // current kB but with the last f changed to an e.
       const NOVEL_KB = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdee";
       const oldUser = Object.assign({}, loggedInUser, {kB: NOVEL_KB});
       server.installCollection("storage-sync-crypto");
       server.installDeleteBucket();
       server.etag = 765;
-      yield* withSignedInUser(oldUser, function* () {
+      yield* withSignedInUser(oldUser, function* (extensionStorageSync, fxaService) {
+        const transformer = new KeyRingEncryptionRemoteTransformer(fxaService);
         const FAKE_KEYRING = {
           id: "keys",
           keys: {},
           salts: {},
           uuid: "abcd",
           kbHash: "abcd",
         };
         yield server.encryptAndAddRecord(transformer, "storage-sync-crypto", FAKE_KEYRING);
       });
 
       // Now we have this new user with a different kB.
-      yield* withSignedInUser(loggedInUser, function* () {
-        yield cryptoCollection._clear();
+      yield* withSignedInUser(loggedInUser, function* (extensionStorageSync, fxaService) {
+        yield extensionStorageSync.cryptoCollection._clear();
 
         // Do an `ensureCanSync` to generate some keys.
         // This will try to sync, notice that the record is
         // undecryptable, and clear the server.
-        let collectionKeys = yield ExtensionStorageSync.ensureCanSync([extensionId]);
+        let collectionKeys = yield extensionStorageSync.ensureCanSync([extensionId]);
         ok(collectionKeys.hasKeysFor([extensionId]),
            `ensureCanSync should always return a keyring with a key for ${extensionId}`);
         extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64;
 
         deepEqual(server.getDeletedBuckets(), ["default"],
                   "Kinto server should have been wiped when keyring was thrown away");
 
         let posts = server.getPosts();
@@ -780,64 +799,65 @@ add_task(function* checkSyncKeyRing_over
         // The POST was to an empty server, so etag shouldn't be respected
         equal(postedKeys.headers.Authorization, "Bearer some-access-token",
               "keyring upload should be authorized");
         equal(postedKeys.headers["If-None-Match"], "*",
               "keyring upload should be to empty Kinto server");
         equal(postedKeys.path, collectionRecordsPath("storage-sync-crypto") + "/keys",
               "keyring upload should be to keyring path");
 
-        let body = yield new KeyRingEncryptionRemoteTransformer().decode(postedKeys.body.data);
+        let body = yield new KeyRingEncryptionRemoteTransformer(fxaService).decode(postedKeys.body.data);
         ok(body.uuid, "new keyring should have a UUID");
         equal(typeof body.uuid, "string", "keyring UUIDs should be strings");
         notEqual(body.uuid, "abcd",
                  "new keyring should not have the same UUID as previous keyring");
         ok(body.keys,
            "new keyring should have a keys attribute");
         ok(body.keys.default, "new keyring should have a default key");
         // We should keep the extension key that was in our uploaded version.
         deepEqual(extensionKey, body.keys.collections[extensionId],
                   "ensureCanSync should have returned keyring with the same key that was uploaded");
 
         // This should be a no-op; the keys were uploaded as part of ensurekeysfor
-        yield ExtensionStorageSync.checkSyncKeyRing();
+        yield extensionStorageSync.checkSyncKeyRing();
         equal(server.getPosts().length, 1,
               "checkSyncKeyRing should not need to post keys after they were reuploaded");
       });
     });
   });
 });
 
 add_task(function* checkSyncKeyRing_flushes_on_uuid_change() {
   // If we can decrypt the record, but the UUID has changed, that
   // means another client has wiped the server and reuploaded a
   // keyring, so reset sync state and reupload everything.
   const extensionId = uuid();
   const extension = {id: extensionId};
-  const transformer = new KeyRingEncryptionRemoteTransformer();
   yield* withSyncContext(function* (context) {
     yield* withServer(function* (server) {
       server.installCollection("storage-sync-crypto");
       server.installDeleteBucket();
-      yield* withSignedInUser(loggedInUser, function* () {
-        yield cryptoCollection._clear();
+      yield* withSignedInUser(loggedInUser, function* (extensionStorageSync, fxaService) {
+        const cryptoCollection = new CryptoCollection(fxaService);
+        const transformer = new KeyRingEncryptionRemoteTransformer(fxaService);
+        yield extensionStorageSync.cryptoCollection._clear();
 
         // Do an `ensureCanSync` to get access to keys and salt.
-        let collectionKeys = yield ExtensionStorageSync.ensureCanSync([extensionId]);
+        let collectionKeys = yield extensionStorageSync.ensureCanSync([extensionId]);
         const collectionId = yield cryptoCollection.extensionIdToCollectionId(extensionId);
         server.installCollection(collectionId);
 
         ok(collectionKeys.hasKeysFor([extensionId]),
            `ensureCanSync should always return a keyring that has a key for ${extensionId}`);
         const extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64;
 
         // Set something to make sure that it gets re-uploaded when
         // uuid changes.
-        yield ExtensionStorageSync.set(extension, {"my-key": 5}, context);
-        yield ExtensionStorageSync.syncAll();
+        yield extensionStorageSync.set(extension, {"my-key": 5}, context);
+        yield extensionStorageSync.syncAll();
 
         let posts = server.getPosts();
         equal(posts.length, 2,
               "should have posted a new keyring and an extension datum");
         const postedKeys = posts[0];
         equal(postedKeys.path, collectionRecordsPath("storage-sync-crypto") + "/keys",
               "should have posted keyring to /keys");
 
@@ -866,140 +886,136 @@ add_task(function* checkSyncKeyRing_flus
         });
         server.clearCollection("storage-sync-crypto");
         server.etag = 765;
         yield server.encryptAndAddRecordOnlyOnce(transformer, "storage-sync-crypto", newKeyRingData);
 
         // Fake adding another extension just so that the keyring will
         // really get synced.
         const newExtension = uuid();
-        const newKeyRing = yield ExtensionStorageSync.ensureCanSync([newExtension]);
+        const newKeyRing = yield extensionStorageSync.ensureCanSync([newExtension]);
 
         // This should have detected the UUID change and flushed everything.
         // The keyring should, however, be the same, since we just
         // changed the UUID of the previously POSTed one.
         deepEqual(newKeyRing.keyForCollection(extensionId).keyPairB64, extensionKey,
                   "ensureCanSync should have pulled down a new keyring with the same keys");
 
         // Syncing should reupload the data for the extension.
-        yield ExtensionStorageSync.syncAll();
+        yield extensionStorageSync.syncAll();
         posts = server.getPosts();
         equal(posts.length, 4,
               "should have posted keyring for new extension and reuploaded extension data");
 
         const finalKeyRingPost = posts[2];
         const reuploadedPost = posts[3];
 
         equal(finalKeyRingPost.path, collectionRecordsPath("storage-sync-crypto") + "/keys",
               "keyring for new extension should have been posted to /keys");
         let finalKeyRing = yield transformer.decode(finalKeyRingPost.body.data);
         equal(finalKeyRing.uuid, "abcd",
               "newly uploaded keyring should preserve UUID from replacement keyring");
         deepEqual(finalKeyRing.salts[extensionId], extensionSalt,
                   "newly uploaded keyring should preserve salts from existing salts");
 
         // Confirm that the data got reuploaded
-        const hashedId = "id-" + (yield cryptoCollection.hashWithExtensionSalt("key-my_2D_key", extensionId));
-        equal(reuploadedPost.path, `${collectionRecordsPath(collectionId)}/${hashedId}`,
-              "extension data should be posted to path corresponding to its key");
-        let reuploadedData = yield new CollectionKeyEncryptionRemoteTransformer(extensionId).decode(reuploadedPost.body.data);
-        equal(reuploadedData.key, "my-key",
-              "extension data should have a key attribute corresponding to the extension data key");
+        let reuploadedData = yield assertExtensionRecord(fxaService, reuploadedPost, extension, "my-key");
         equal(reuploadedData.data, 5,
               "extension data should have a data attribute corresponding to the extension data value");
       });
     });
   });
 });
 
 add_task(function* test_storage_sync_pulls_changes() {
   const extensionId = defaultExtensionId;
   const extension = defaultExtension;
   yield* withContextAndServer(function* (context, server) {
-    yield* withSignedInUser(loggedInUser, function* () {
-      let transformer = new CollectionKeyEncryptionRemoteTransformer(extensionId);
+    yield* withSignedInUser(loggedInUser, function* (extensionStorageSync, fxaService) {
+      const cryptoCollection = new CryptoCollection(fxaService);
+      let transformer = new CollectionKeyEncryptionRemoteTransformer(cryptoCollection, extensionId);
       server.installCollection("storage-sync-crypto");
 
       let calls = [];
-      yield ExtensionStorageSync.addOnChangedListener(extension, function() {
+      yield extensionStorageSync.addOnChangedListener(extension, function() {
         calls.push(arguments);
       }, context);
 
-      yield ExtensionStorageSync.ensureCanSync([extensionId]);
+      yield extensionStorageSync.ensureCanSync([extensionId]);
       const collectionId = yield cryptoCollection.extensionIdToCollectionId(extensionId);
       server.installCollection(collectionId);
       yield server.encryptAndAddRecord(transformer, collectionId, {
         "id": "key-remote_2D_key",
         "key": "remote-key",
         "data": 6,
       });
 
-      yield ExtensionStorageSync.syncAll();
-      const remoteValue = (yield ExtensionStorageSync.get(extension, "remote-key", context))["remote-key"];
+      yield extensionStorageSync.syncAll();
+      const remoteValue = (yield extensionStorageSync.get(extension, "remote-key", context))["remote-key"];
       equal(remoteValue, 6,
             "ExtensionStorageSync.get() returns value retrieved from sync");
 
       equal(calls.length, 1,
             "syncing calls on-changed listener");
       deepEqual(calls[0][0], {"remote-key": {newValue: 6}});
       calls = [];
 
       // Syncing again doesn't do anything
-      yield ExtensionStorageSync.syncAll();
+      yield extensionStorageSync.syncAll();
 
       equal(calls.length, 0,
             "syncing again shouldn't call on-changed listener");
 
       // Updating the server causes us to pull down the new value
       server.etag = 1000;
       server.clearCollection(collectionId);
       yield server.encryptAndAddRecord(transformer, collectionId, {
         "id": "key-remote_2D_key",
         "key": "remote-key",
         "data": 7,
       });
 
-      yield ExtensionStorageSync.syncAll();
-      const remoteValue2 = (yield ExtensionStorageSync.get(extension, "remote-key", context))["remote-key"];
+      yield extensionStorageSync.syncAll();
+      const remoteValue2 = (yield extensionStorageSync.get(extension, "remote-key", context))["remote-key"];
       equal(remoteValue2, 7,
             "ExtensionStorageSync.get() returns value updated from sync");
 
       equal(calls.length, 1,
             "syncing calls on-changed listener on update");
       deepEqual(calls[0][0], {"remote-key": {oldValue: 6, newValue: 7}});
     });
   });
 });
 
 add_task(function* test_storage_sync_pushes_changes() {
   // FIXME: This test relies on the fact that previous tests pushed
   // keys and salts for the default extension ID
+  const extension = defaultExtension;
   const extensionId = defaultExtensionId;
-  const collectionId = yield cryptoCollection.extensionIdToCollectionId(extensionId);
-  const extension = defaultExtension;
   yield* withContextAndServer(function* (context, server) {
-    yield* withSignedInUser(loggedInUser, function* () {
-      let transformer = new CollectionKeyEncryptionRemoteTransformer(extensionId);
+    yield* withSignedInUser(loggedInUser, function* (extensionStorageSync, fxaService) {
+      const cryptoCollection = new CryptoCollection(fxaService);
+      const collectionId = yield cryptoCollection.extensionIdToCollectionId(extensionId);
       server.installCollection(collectionId);
       server.installCollection("storage-sync-crypto");
       server.etag = 1000;
 
-      yield ExtensionStorageSync.set(extension, {"my-key": 5}, context);
+      yield extensionStorageSync.set(extension, {"my-key": 5}, context);
 
       // install this AFTER we set the key to 5...
       let calls = [];
-      ExtensionStorageSync.addOnChangedListener(extension, function() {
+      extensionStorageSync.addOnChangedListener(extension, function() {
         calls.push(arguments);
       }, context);
 
-      yield ExtensionStorageSync.syncAll();
-      const localValue = (yield ExtensionStorageSync.get(extension, "my-key", context))["my-key"];
-      const hashedId = "id-" + (yield cryptoCollection.hashWithExtensionSalt("key-my_2D_key", extensionId));
+      yield extensionStorageSync.syncAll();
+      const localValue = (yield extensionStorageSync.get(extension, "my-key", context))["my-key"];
       equal(localValue, 5,
             "pushing an ExtensionStorageSync value shouldn't change local value");
+      const hashedId = "id-" + (yield cryptoCollection.hashWithExtensionSalt("key-my_2D_key", extensionId));
 
       let posts = server.getPosts();
       // FIXME: Keys were pushed in a previous test
       equal(posts.length, 1,
             "pushing a value should cause a post to the server");
       const post = posts[0];
       assertPostedNewRecord(post);
       equal(post.path, `${collectionRecordsPath(collectionId)}/${hashedId}`,
@@ -1008,29 +1024,27 @@ add_task(function* test_storage_sync_pus
       const encrypted = post.body.data;
       ok(encrypted.ciphertext,
          "pushing a value should post an encrypted record");
       ok(!encrypted.data,
          "pushing a value should not have any plaintext data");
       equal(encrypted.id, hashedId,
             "pushing a value should use a kinto-friendly record ID");
 
-      const record = yield transformer.decode(encrypted);
-      equal(record.key, "my-key",
-            "when decrypted, a pushed value should have a key field corresponding to its storage.sync key");
+      const record = yield assertExtensionRecord(fxaService, post, extension, "my-key");
       equal(record.data, 5,
             "when decrypted, a pushed value should have a data field corresponding to its storage.sync value");
       equal(record.id, "key-my_2D_key",
             "when decrypted, a pushed value should have an id field corresponding to its record ID");
 
       equal(calls.length, 0,
             "pushing a value shouldn't call the on-changed listener");
 
-      yield ExtensionStorageSync.set(extension, {"my-key": 6}, context);
-      yield ExtensionStorageSync.syncAll();
+      yield extensionStorageSync.set(extension, {"my-key": 6}, context);
+      yield extensionStorageSync.syncAll();
 
       // Doesn't push keys because keys were pushed by a previous test.
       posts = server.getPosts();
       equal(posts.length, 2,
             "updating a value should trigger another push");
       const updatePost = posts[1];
       assertPostedUpdatedRecord(updatePost, 1000);
       equal(updatePost.path, `${collectionRecordsPath(collectionId)}/${hashedId}`,
@@ -1043,104 +1057,108 @@ add_task(function* test_storage_sync_pus
          "pushing an updated value should not have any plaintext visible");
       equal(updateEncrypted.id, hashedId,
             "pushing an updated value should maintain the same ID");
     });
   });
 });
 
 add_task(function* test_storage_sync_pulls_deletes() {
-  const collectionId = yield cryptoCollection.extensionIdToCollectionId(defaultExtensionId);
   const extension = defaultExtension;
   yield* withContextAndServer(function* (context, server) {
-    yield* withSignedInUser(loggedInUser, function* () {
+    yield* withSignedInUser(loggedInUser, function* (extensionStorageSync, fxaService) {
+      const cryptoCollection = new CryptoCollection(fxaService);
+      const collectionId = yield cryptoCollection.extensionIdToCollectionId(defaultExtensionId);
       server.installCollection(collectionId);
       server.installCollection("storage-sync-crypto");
 
-      yield ExtensionStorageSync.set(extension, {"my-key": 5}, context);
-      yield ExtensionStorageSync.syncAll();
+      yield extensionStorageSync.set(extension, {"my-key": 5}, context);
+      yield extensionStorageSync.syncAll();
       server.clearPosts();
 
       let calls = [];
-      yield ExtensionStorageSync.addOnChangedListener(extension, function() {
+      yield extensionStorageSync.addOnChangedListener(extension, function() {
         calls.push(arguments);
       }, context);
 
-      yield server.encryptAndAddRecord(new CollectionKeyEncryptionRemoteTransformer(extension.id), collectionId, {
+      const transformer = new CollectionKeyEncryptionRemoteTransformer(new CryptoCollection(fxaService), extension.id);
+      yield server.encryptAndAddRecord(transformer, collectionId, {
         "id": "key-my_2D_key",
         "data": 6,
         "_status": "deleted",
       });
 
-      yield ExtensionStorageSync.syncAll();
-      const remoteValues = (yield ExtensionStorageSync.get(extension, "my-key", context));
+      yield extensionStorageSync.syncAll();
+      const remoteValues = (yield extensionStorageSync.get(extension, "my-key", context));
       ok(!remoteValues["my-key"],
          "ExtensionStorageSync.get() shows value was deleted by sync");
 
       equal(server.getPosts().length, 0,
             "pulling the delete shouldn't cause posts");
 
       equal(calls.length, 1,
             "syncing calls on-changed listener");
       deepEqual(calls[0][0], {"my-key": {oldValue: 5}});
       calls = [];
 
       // Syncing again doesn't do anything
-      yield ExtensionStorageSync.syncAll();
+      yield extensionStorageSync.syncAll();
 
       equal(calls.length, 0,
             "syncing again shouldn't call on-changed listener");
     });
   });
 });
 
 add_task(function* test_storage_sync_pushes_deletes() {
   const extensionId = uuid();
   const extension = {id: extensionId};
-  yield cryptoCollection._clear();
-  yield cryptoCollection._setSalt(extensionId, cryptoCollection.getNewSalt());
-  const collectionId = yield cryptoCollection.extensionIdToCollectionId(extensionId);
   yield* withContextAndServer(function* (context, server) {
-    yield* withSignedInUser(loggedInUser, function* () {
+    yield* withSignedInUser(loggedInUser, function* (extensionStorageSync, fxaService) {
+      const cryptoCollection = new CryptoCollection(fxaService);
+      yield cryptoCollection._clear();
+      yield cryptoCollection._setSalt(extensionId, cryptoCollection.getNewSalt());
+      const collectionId = yield cryptoCollection.extensionIdToCollectionId(extensionId);
+
       server.installCollection(collectionId);
       server.installCollection("storage-sync-crypto");
       server.etag = 1000;
 
-      yield ExtensionStorageSync.set(extension, {"my-key": 5}, context);
+      yield extensionStorageSync.set(extension, {"my-key": 5}, context);
 
       let calls = [];
-      ExtensionStorageSync.addOnChangedListener(extension, function() {
+      extensionStorageSync.addOnChangedListener(extension, function() {
         calls.push(arguments);
       }, context);
 
-      yield ExtensionStorageSync.syncAll();
+      yield extensionStorageSync.syncAll();
       let posts = server.getPosts();
       equal(posts.length, 2,
             "pushing a non-deleted value should post keys and post the value to the server");
 
-      yield ExtensionStorageSync.remove(extension, ["my-key"], context);
+      yield extensionStorageSync.remove(extension, ["my-key"], context);
       equal(calls.length, 1,
             "deleting a value should call the on-changed listener");
 
-      yield ExtensionStorageSync.syncAll();
+      yield extensionStorageSync.syncAll();
       equal(calls.length, 1,
             "pushing a deleted value shouldn't call the on-changed listener");
 
       // Doesn't push keys because keys were pushed by a previous test.
       const hashedId = "id-" + (yield cryptoCollection.hashWithExtensionSalt("key-my_2D_key", extensionId));
       posts = server.getPosts();
       equal(posts.length, 3,
             "deleting a value should trigger another push");
       const post = posts[2];
       assertPostedUpdatedRecord(post, 1000);
       equal(post.path, `${collectionRecordsPath(collectionId)}/${hashedId}`,
             "pushing a deleted value should go to the same path");
       ok(post.method, "PUT");
       ok(post.body.data.ciphertext,
          "deleting a value should have an encrypted body");
-      const decoded = yield new CollectionKeyEncryptionRemoteTransformer(extensionId).decode(post.body.data);
+      const decoded = yield new CollectionKeyEncryptionRemoteTransformer(cryptoCollection, extensionId).decode(post.body.data);
       equal(decoded._status, "deleted");
       // Ideally, we'd check that decoded.deleted is not true, because
       // the encrypted record shouldn't have it, but the decoder will
       // add it when it sees _status == deleted
     });
   });
 });