Bug 1321570 - Move ExtensionStorageSync crypto out of services/sync, r?kmag draft
authorEthan Glasser-Camp <eglassercamp@mozilla.com>
Mon, 13 Feb 2017 15:06:02 -0500
changeset 484096 2fa8f94ecda0eb02a4b74e7bb3eb1dfeace08595
parent 482882 e1a4314f8e6eae8bbc06394c14132a9c5011371b
child 484097 2075fb12801be4c9da4755d6416c45c953a564d6
child 484828 e0bf4e6a28a6f8aa9b6db5af5cd6638b6217e3e2
push id45396
push usereglassercamp@mozilla.com
push dateTue, 14 Feb 2017 20:48:43 +0000
reviewerskmag
bugs1321570
milestone54.0a1
Bug 1321570 - Move ExtensionStorageSync crypto out of services/sync, r?kmag Since services/sync doesn't ship on Android, this meant conditionally-defining some variables such as `cryptoCollection` and `CollectionKeyEncryptionRemoteTransformer` depending on whether or not we were on Android. However, none of these definitions really rely on functionality that isn't present on Android (although you can't really use them yet either). Move the dependency together with the dependant code so we can simplify a bit. This lets us remove conditional uses of `cryptoCollection` and `CollectionKeyEncryptionRemoteTransformer`. Because the WebExtensions source directory has more stringent eslint rules, we end up reformatting and commenting a bit in addition to moving. MozReview-Commit-ID: 2ddDeymYFNi
services/sync/modules/engines/extension-storage.js
services/sync/tests/unit/test_extension_storage_crypto.js
services/sync/tests/unit/xpcshell.ini
toolkit/components/extensions/ExtensionStorageSync.jsm
toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js
toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_crypto.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
--- a/services/sync/modules/engines/extension-storage.js
+++ b/services/sync/modules/engines/extension-storage.js
@@ -1,23 +1,20 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-this.EXPORTED_SYMBOLS = ["ExtensionStorageEngine", "EncryptionRemoteTransformer",
-                         "KeyRingEncryptionRemoteTransformer"];
+this.EXPORTED_SYMBOLS = ["ExtensionStorageEngine"];
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
-Cu.import("resource://services-crypto/utils.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/engines.js");
-Cu.import("resource://services-sync/keys.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-common/async.js");
 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");
@@ -100,194 +97,8 @@ ExtensionStorageTracker.prototype = {
   },
   addChangedID() {
   },
   removeChangedID() {
   },
   clearChangedIDs() {
   },
 };
-
-/**
- * Utility function to enforce an order of fields when computing an HMAC.
- */
-function ciphertextHMAC(keyBundle, id, IV, ciphertext) {
-  const hasher = keyBundle.sha256HMACHasher;
-  return Utils.bytesAsHex(Utils.digestUTF8(id + IV + ciphertext, 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) {
-    const self = this;
-    return Task.spawn(function* () {
-      const keyBundle = yield self.getKeys();
-      if (record.ciphertext) {
-        throw new Error("Attempt to reencrypt??");
-      }
-      let id = yield self.getEncodedRecordId(record);
-      if (!id) {
-        throw new Error("Record ID is missing or invalid");
-      }
-
-      let IV = Svc.Crypto.generateRandomIV();
-      let ciphertext = Svc.Crypto.encrypt(JSON.stringify(record),
-                                          keyBundle.encryptionKeyB64, IV);
-      let hmac = ciphertextHMAC(keyBundle, id, IV, ciphertext);
-      const encryptedResult = {ciphertext, IV, hmac, id};
-
-      // Copy over the _status field, so that we handle concurrency
-      // headers (If-Match, If-None-Match) correctly.
-      // DON'T copy over "deleted" status, because then we'd leak
-      // plaintext deletes.
-      encryptedResult._status = record._status == "deleted" ? "updated" : record._status;
-      if (record.hasOwnProperty("last_modified")) {
-        encryptedResult.last_modified = record.last_modified;
-      }
-
-      return encryptedResult;
-    });
-  }
-
-  decode(record) {
-    const self = this;
-    return Task.spawn(function* () {
-      if (!record.ciphertext) {
-        // This can happen for tombstones if a record is deleted.
-        if (record.deleted) {
-          return record;
-        }
-        throw new Error("No ciphertext: nothing to decrypt?");
-      }
-      const keyBundle = yield self.getKeys();
-      // Authenticate the encrypted blob with the expected HMAC
-      let computedHMAC = ciphertextHMAC(keyBundle, record.id, record.IV, record.ciphertext);
-
-      if (computedHMAC != record.hmac) {
-        Utils.throwHMACMismatch(record.hmac, computedHMAC);
-      }
-
-      // Handle invalid data here. Elsewhere we assume that cleartext is an object.
-      let cleartext = Svc.Crypto.decrypt(record.ciphertext,
-                                         keyBundle.encryptionKeyB64, record.IV);
-      let jsonResult = JSON.parse(cleartext);
-      if (!jsonResult || typeof jsonResult !== "object") {
-        throw new Error("Decryption failed: result is <" + jsonResult + ">, not an object.");
-      }
-
-      if (record.hasOwnProperty("last_modified")) {
-        jsonResult.last_modified = record.last_modified;
-      }
-
-      // _status: deleted records were deleted on a client, but
-      // uploaded as an encrypted blob so we don't leak deletions.
-      // If we get such a record, flag it as deleted.
-      if (jsonResult._status == "deleted") {
-        jsonResult.deleted = true;
-      }
-
-      return jsonResult;
-    });
-  }
-
-  /**
-   * Retrieve keys to use during encryption.
-   *
-   * Returns a Promise<KeyBundle>.
-   */
-  getKeys() {
-    throw new Error("override getKeys in a subclass");
-  }
-
-  /**
-   * Compute the record ID to use for the encoded version of the
-   * record.
-   *
-   * The default version just re-uses the record's ID.
-   *
-   * @param {Object} record The record being encoded.
-   * @returns {Promise<string>} The ID to use.
-   */
-  getEncodedRecordId(record) {
-    return Promise.resolve(record.id);
-  }
-}
-// You can inject this
-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 {
-  getKeys() {
-    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");
-      }
-
-      if (!user.kB) {
-        throw new Error("user doesn't have kB");
-      }
-
-      let kB = Utils.hexToBytes(user.kB);
-
-      let keyMaterial = CryptoUtils.hkdf(kB, undefined,
-                                       "identity.mozilla.com/picl/v1/chrome.storage.sync", 2 * 32);
-      let bundle = new BulkKeyBundle();
-      // [encryptionKey, hmacKey]
-      bundle.keyPair = [keyMaterial.slice(0, 32), keyMaterial.slice(32, 64)];
-      return bundle;
-    });
-  }
-  // Pass through the kbHash field from the unencrypted record. If
-  // encryption fails, we can use this to try to detect whether we are
-  // being compromised or if the record here was encoded with a
-  // different kB.
-  encode(record) {
-    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);
-          }
-        }
-        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) {
-    const kbMessage = "kB hash on record is outdated: ";
-    return exc && exc.message && exc.message.indexOf &&
-      (exc.message.indexOf(kbMessage) == 0);
-  }
-}
--- a/services/sync/tests/unit/xpcshell.ini
+++ b/services/sync/tests/unit/xpcshell.ini
@@ -140,17 +140,16 @@ tags = addons
 [test_bookmark_store.js]
 # Too many intermittent "ASSERTION: thread pool wasn't shutdown: '!mPool'" (bug 804479)
 skip-if = debug
 [test_bookmark_tracker.js]
 requesttimeoutfactor = 4
 [test_bookmark_validator.js]
 [test_clients_engine.js]
 [test_clients_escape.js]
-[test_extension_storage_crypto.js]
 [test_extension_storage_engine.js]
 [test_extension_storage_tracker.js]
 [test_forms_store.js]
 [test_forms_tracker.js]
 # Too many intermittent "ASSERTION: thread pool wasn't shutdown: '!mPool'" (bug 804479)
 skip-if = debug
 [test_history_engine.js]
 [test_history_store.js]
--- a/toolkit/components/extensions/ExtensionStorageSync.jsm
+++ b/toolkit/components/extensions/ExtensionStorageSync.jsm
@@ -35,59 +35,259 @@ const KINTO_REQUEST_TIMEOUT = 30000;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 const {
   runSafeSyncWithoutClone,
 } = 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",
                                   "resource://services-common/utils.js");
 XPCOMUtils.defineLazyModuleGetter(this, "CryptoUtils",
                                   "resource://services-crypto/utils.js");
-XPCOMUtils.defineLazyModuleGetter(this, "EncryptionRemoteTransformer",
-                                  "resource://services-sync/engines/extension-storage.js");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage",
                                   "resource://gre/modules/ExtensionStorage.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
                                   "resource://gre/modules/FxAccounts.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "KintoHttpClient",
                                   "resource://services-common/kinto-http-client.js");
 XPCOMUtils.defineLazyModuleGetter(this, "Kinto",
                                   "resource://services-common/kinto-offline-client.js");
 XPCOMUtils.defineLazyModuleGetter(this, "FirefoxAdapter",
                                   "resource://services-common/kinto-storage-adapter.js");
 XPCOMUtils.defineLazyModuleGetter(this, "Log",
                                   "resource://gre/modules/Log.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Observers",
                                   "resource://services-common/observers.js");
 XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
                                   "resource://gre/modules/Sqlite.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Svc",
+                                  "resource://services-sync/util.js");
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                   "resource://gre/modules/Task.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "KeyRingEncryptionRemoteTransformer",
-                                  "resource://services-sync/engines/extension-storage.js");
+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);
 
 /* 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");
 
 /**
+ * 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));
+}
+
+/**
+ * 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) {
+    const self = this;
+    return Task.spawn(function* () {
+      const keyBundle = yield self.getKeys();
+      if (record.ciphertext) {
+        throw new Error("Attempt to reencrypt??");
+      }
+      let id = yield self.getEncodedRecordId(record);
+      if (!id) {
+        throw new Error("Record ID is missing or invalid");
+      }
+
+      let IV = Svc.Crypto.generateRandomIV();
+      let ciphertext = Svc.Crypto.encrypt(JSON.stringify(record),
+                                          keyBundle.encryptionKeyB64, IV);
+      let hmac = ciphertextHMAC(keyBundle, id, IV, ciphertext);
+      const encryptedResult = {ciphertext, IV, hmac, id};
+
+      // Copy over the _status field, so that we handle concurrency
+      // headers (If-Match, If-None-Match) correctly.
+      // DON'T copy over "deleted" status, because then we'd leak
+      // plaintext deletes.
+      encryptedResult._status = record._status == "deleted" ? "updated" : record._status;
+      if (record.hasOwnProperty("last_modified")) {
+        encryptedResult.last_modified = record.last_modified;
+      }
+
+      return encryptedResult;
+    });
+  }
+
+  decode(record) {
+    const self = this;
+    return Task.spawn(function* () {
+      if (!record.ciphertext) {
+        // This can happen for tombstones if a record is deleted.
+        if (record.deleted) {
+          return record;
+        }
+        throw new Error("No ciphertext: nothing to decrypt?");
+      }
+      const keyBundle = yield self.getKeys();
+      // Authenticate the encrypted blob with the expected HMAC
+      let computedHMAC = ciphertextHMAC(keyBundle, record.id, record.IV, record.ciphertext);
+
+      if (computedHMAC != record.hmac) {
+        Utils.throwHMACMismatch(record.hmac, computedHMAC);
+      }
+
+      // Handle invalid data here. Elsewhere we assume that cleartext is an object.
+      let cleartext = Svc.Crypto.decrypt(record.ciphertext,
+                                         keyBundle.encryptionKeyB64, record.IV);
+      let jsonResult = JSON.parse(cleartext);
+      if (!jsonResult || typeof jsonResult !== "object") {
+        throw new Error("Decryption failed: result is <" + jsonResult + ">, not an object.");
+      }
+
+      if (record.hasOwnProperty("last_modified")) {
+        jsonResult.last_modified = record.last_modified;
+      }
+
+      // _status: deleted records were deleted on a client, but
+      // uploaded as an encrypted blob so we don't leak deletions.
+      // If we get such a record, flag it as deleted.
+      if (jsonResult._status == "deleted") {
+        jsonResult.deleted = true;
+      }
+
+      return jsonResult;
+    });
+  }
+
+  /**
+   * Retrieve keys to use during encryption.
+   *
+   * Returns a Promise<KeyBundle>.
+   */
+  getKeys() {
+    throw new Error("override getKeys in a subclass");
+  }
+
+  /**
+   * Compute the record ID to use for the encoded version of the
+   * record.
+   *
+   * The default version just re-uses the record's ID.
+   *
+   * @param {Object} record The record being encoded.
+   * @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 {
+  getKeys() {
+    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");
+      }
+
+      if (!user.kB) {
+        throw new Error("user doesn't have kB");
+      }
+
+      let kB = Utils.hexToBytes(user.kB);
+
+      let keyMaterial = CryptoUtils.hkdf(kB, undefined,
+                                       "identity.mozilla.com/picl/v1/chrome.storage.sync", 2 * 32);
+      let bundle = new BulkKeyBundle();
+      // [encryptionKey, hmacKey]
+      bundle.keyPair = [keyMaterial.slice(0, 32), keyMaterial.slice(32, 64)];
+      return bundle;
+    });
+  }
+  // Pass through the kbHash field from the unencrypted record. If
+  // encryption fails, we can use this to try to detect whether we are
+  // being compromised or if the record here was encoded with a
+  // different kB.
+  encode(record) {
+    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);
+          }
+        }
+        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) {
+    const kbMessage = "kB hash on record is outdated: ";
+    return exc && exc.message && exc.message.indexOf &&
+      (exc.message.indexOf(kbMessage) == 0);
+  }
+}
+global.KeyRingEncryptionRemoteTransformer = KeyRingEncryptionRemoteTransformer;
+
+/**
  * A Promise that centralizes initialization of ExtensionStorageSync.
  *
  * This centralizes the use of the Sqlite database, to which there is
  * only one connection which is shared by all threads.
  *
  * Fields in the object returned by this Promise:
  *
  * - connection: a Sqlite connection. Meant for internal use only.
@@ -176,246 +376,245 @@ const cryptoCollectionIdSchema = {
     throw new Error("cannot generate IDs for system collection");
   },
 
   validate(id) {
     return true;
   },
 };
 
-let cryptoCollection, CollectionKeyEncryptionRemoteTransformer;
-if (AppConstants.platform != "android") {
+/**
+ * Wrapper around the crypto collection providing some handy utilities.
+ */
+let cryptoCollection = this.cryptoCollection = {
+  getCollection: Task.async(function* () {
+    const {kinto} = yield storageSyncInit;
+    return kinto.collection(STORAGE_SYNC_CRYPTO_COLLECTION_NAME, {
+      idSchema: cryptoCollectionIdSchema,
+      remoteTransformers: [new KeyRingEncryptionRemoteTransformer()],
+    });
+  }),
+
   /**
-   * Wrapper around the crypto collection providing some handy utilities.
+   * Generate a new salt for use in hashing extension and record
+   * IDs.
+   *
+   * @returns {string} A base64-encoded string of the salt
    */
-  cryptoCollection = this.cryptoCollection = {
-    getCollection: Task.async(function* () {
-      const {kinto} = yield storageSyncInit;
-      return kinto.collection(STORAGE_SYNC_CRYPTO_COLLECTION_NAME, {
-        idSchema: cryptoCollectionIdSchema,
-        remoteTransformers: [new KeyRingEncryptionRemoteTransformer()],
-      });
-    }),
-
-    /**
-     * 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));
-    },
+  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:
-     *
-     * - kbHash: a hash of the user's kB. When this changes, we will
-     *   try to sync the collection.
-     * - 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);
-
-      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();
-      return cryptoKeyRecord && cryptoKeyRecord.salts;
-    }),
-
-    /**
-     * Used for testing with a known salt.
-     */
-    _setSalt: Task.async(function* (extensionId, salt) {
-      const cryptoKeyRecord = yield this.getKeyRingRecord();
-      cryptoKeyRecord.salts = cryptoKeyRecord.salts || {};
-      cryptoKeyRecord.salts[extensionId] = salt;
-      this.upsert(cryptoKeyRecord);
-    }),
+  /**
+   * 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:
+   *
+   * - kbHash: a hash of the user's kB. When this changes, we will
+   *   try to sync the collection.
+   * - 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);
 
-    /**
-     * 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.
-     *
-     * The returned hash must conform to the syntax for Kinto
-     * identifiers, which (as of this writing) must match
-     * [a-zA-Z0-9][a-zA-Z0-9_-]*. We thus encode the hash using
-     * "base64-url" without padding (so that we don't get any equals
-     * signs (=)). For fear that a hash could start with a hyphen
-     * (-) 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}`);
-    },
+    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;
+  }),
 
-    /**
-     * 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();
-      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);
+  getSalts: Task.async(function* () {
+    const cryptoKeyRecord = yield this.getKeyRingRecord();
+    return cryptoKeyRecord && cryptoKeyRecord.salts;
+  }),
 
-      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();
-      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;
-    }),
+  /**
+   * Used for testing with a known salt.
+   */
+  _setSalt: Task.async(function* (extensionId, salt) {
+    const cryptoKeyRecord = yield this.getKeyRingRecord();
+    cryptoKeyRecord.salts = cryptoKeyRecord.salts || {};
+    cryptoKeyRecord.salts[extensionId] = salt;
+    this.upsert(cryptoKeyRecord);
+  }),
 
-    updateKBHash: Task.async(function* (kbHash) {
-      const coll = yield this.getCollection();
-      yield 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);
-    }),
-
-    sync: Task.async(function* () {
-      const collection = yield this.getCollection();
-      return yield 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();
-    }),
-
-    // Used only for testing.
-    _clear: Task.async(function* () {
-      const collection = yield this.getCollection();
-      yield collection.clear();
-    }),
-  };
+  /**
+   * 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.
+   *
+   * The returned hash must conform to the syntax for Kinto
+   * identifiers, which (as of this writing) must match
+   * [a-zA-Z0-9][a-zA-Z0-9_-]*. We thus encode the hash using
+   * "base64-url" without padding (so that we don't get any equals
+   * signs (=)). For fear that a hash could start with a hyphen
+   * (-) 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}`);
+  },
 
   /**
-   * An EncryptionRemoteTransformer for extension records.
+   * Hash some value with the salt for the given extension.
    *
-   * It uses the special "keys" record to find a key for a given
-   * extension, thus its name
-   * CollectionKeyEncryptionRemoteTransformer.
+   * 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.
    *
-   * 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.
+   * @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.
    */
-  CollectionKeyEncryptionRemoteTransformer = class extends EncryptionRemoteTransformer {
-    constructor(extensionId) {
-      super();
-      this.extensionId = extensionId;
+  hashWithExtensionSalt: Task.async(function* (value, extensionId) {
+    const salts = yield 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?`);
     }
 
-    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();
-        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);
-      });
+    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();
+    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,
+                       kbHash: kbHash},
+                      {patch: true});
+  }),
+
+  upsert: Task.async(function* (record) {
+    const collection = yield this.getCollection();
+    yield collection.upsert(record);
+  }),
+
+  sync: Task.async(function* () {
+    const collection = yield this.getCollection();
+    return yield ExtensionStorageSync._syncCollection(collection, {
+      strategy: "server_wins",
+    });
+  }),
 
-    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)
-        .then(hash => `id-${hash}`);
-    }
-  };
-  global.CollectionKeyEncryptionRemoteTransformer = CollectionKeyEncryptionRemoteTransformer;
-}
+  /**
+   * Reset sync status for ALL collections by directly
+   * accessing the FirefoxAdapter.
+   */
+  resetSyncStatus: Task.async(function* () {
+    const coll = yield this.getCollection();
+    yield coll.db.resetSyncStatus();
+  }),
+
+  // Used only for testing.
+  _clear: Task.async(function* () {
+    const collection = yield this.getCollection();
+    yield collection.clear();
+  }),
+};
+
+/**
+ * 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) {
+    super();
+    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();
+      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);
+    });
+  }
+
+  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)
+      .then(hash => `id-${hash}`);
+  }
+};
+
+global.CollectionKeyEncryptionRemoteTransformer = CollectionKeyEncryptionRemoteTransformer;
+
 /**
  * Clean up now that one context is no longer using this extension's collection.
  *
  * @param {Extension} extension
  *                    The extension whose context just ended.
  * @param {Context} context
  *                  The context that just ended.
  */
@@ -441,37 +640,24 @@ function cleanUpForContext(extension, co
  *                  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) {
   let collectionId = extension.id;
   const {kinto} = yield storageSyncInit;
-  const remoteTransformers = [];
-  if (CollectionKeyEncryptionRemoteTransformer) {
-    remoteTransformers.push(new CollectionKeyEncryptionRemoteTransformer(extension.id));
-  }
+  const remoteTransformers = [new CollectionKeyEncryptionRemoteTransformer(extension.id)];
   const coll = kinto.collection(collectionId, {
     idSchema: storageSyncIdSchema,
     remoteTransformers,
   });
   return coll;
 });
 
-/**
- * Verify that we were built on not-Android. Call this as a sanity
- * check before using cryptoCollection.
- */
-function ensureCryptoCollection() {
-  if (!cryptoCollection) {
-    throw new Error("Call to ensureCanSync, but no sync code; are you on Android?");
-  }
-}
-
 // 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;
 }
 
@@ -653,18 +839,16 @@ this.ExtensionStorageSync = {
    * 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) {
-    ensureCryptoCollection();
-
     const keysRecord = yield cryptoCollection.getKeyRingRecord();
     const collectionKeys = yield cryptoCollection.getKeyRing();
     if (collectionKeys.hasKeysFor(extIds) && this.hasSaltsFor(keysRecord, extIds)) {
       return collectionKeys;
     }
 
     const kbHash = yield this.getKBHash();
     const newKeys = yield collectionKeys.ensureKeysFor(extIds);
@@ -711,18 +895,16 @@ this.ExtensionStorageSync = {
     hasher.init(hasher.SHA256);
     return CommonUtils.bytesAsHex(CryptoUtils.digestBytes(signedInUser.uid + kBbytes, hasher));
   }),
 
   /**
    * Update the kB in the crypto record.
    */
   updateKeyRingKB: Task.async(function* () {
-    ensureCryptoCollection();
-
     const signedInUser = yield 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
@@ -737,34 +919,30 @@ this.ExtensionStorageSync = {
   /**
    * 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* () {
-    ensureCryptoCollection();
-
     yield this.updateKeyRingKB();
 
     const cryptoKeyRecord = yield 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);
     }
   }),
 
   _syncKeyRing: Task.async(function* (cryptoKeyRecord) {
-    ensureCryptoCollection();
-
     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.
       //
--- a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js
@@ -3,29 +3,30 @@
 
 "use strict";
 
 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");
-Cu.import("resource://gre/modules/ExtensionStorageSync.jsm");
 const {
   CollectionKeyEncryptionRemoteTransformer,
   cryptoCollection,
+  EncryptionRemoteTransformer,
+  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");
 
 /* globals BulkKeyBundle, CommonUtils, EncryptionRemoteTransformer */
-/* globals KeyRingEncryptionRemoteTransformer */
 /* globals Utils */
 
 function handleCannedResponse(cannedResponse, request, response) {
   response.setStatusLine(null, cannedResponse.status.status,
                          cannedResponse.status.statusText);
   // send the headers
   for (let headerLine of cannedResponse.sampleHeaders) {
     let headerElements = headerLine.split(":");
rename from services/sync/tests/unit/test_extension_storage_crypto.js
rename to toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_crypto.js
--- a/services/sync/tests/unit/test_extension_storage_crypto.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_crypto.js
@@ -1,15 +1,17 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
+const {
+  EncryptionRemoteTransformer,
+} = Cu.import("resource://gre/modules/ExtensionStorageSync.jsm", {});
 Cu.import("resource://services-crypto/utils.js");
-Cu.import("resource://services-sync/engines/extension-storage.js");
 Cu.import("resource://services-sync/util.js");
 
 /**
  * Like Assert.throws, but for generators.
  *
  * @param {string | Object | function} constraint
  *        What to use to check the exception.
  * @param {function} f
@@ -33,17 +35,16 @@ function* throwsGen(constraint, f) {
     message = exception.message;
   }
 
   if (typeof constraint === "function") {
     ok(constraint(message), debuggingMessage);
   } else {
     ok(constraint === message, debuggingMessage);
   }
-
 }
 
 /**
  * An EncryptionRemoteTransformer that uses a fixed key bundle,
  * suitable for testing.
  */
 class StaticKeyEncryptionRemoteTransformer extends EncryptionRemoteTransformer {
   constructor(keyBundle) {
@@ -76,17 +77,17 @@ add_task(function* test_encryption_trans
 
     deepEqual(record, yield transformer.decode(yield transformer.encode(record)));
   }
 });
 
 add_task(function* test_refuses_to_decrypt_tampered() {
   const encryptedRecord = yield transformer.encode({data: [1, 2, 3], id: "key-some_2D_key", key: "some-key"});
   const tamperedHMAC = Object.assign({}, encryptedRecord, {hmac: "0000000000000000000000000000000000000000000000000000000000000001"});
-  yield* throwsGen(Utils.isHMACMismatch, function*() {
+  yield* throwsGen(Utils.isHMACMismatch, function* () {
     yield transformer.decode(tamperedHMAC);
   });
 
   const tamperedIV = Object.assign({}, encryptedRecord, {IV: "aaaaaaaaaaaaaaaaaaaaaa=="});
-  yield* throwsGen(Utils.isHMACMismatch, function*() {
+  yield* throwsGen(Utils.isHMACMismatch, function* () {
     yield transformer.decode(tamperedIV);
   });
 });
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -58,16 +58,18 @@ skip-if = release_or_beta
 [test_ext_schemas_async.js]
 [test_ext_schemas_allowed_contexts.js]
 [test_ext_shutdown_cleanup.js]
 [test_ext_simple.js]
 [test_ext_storage.js]
 [test_ext_storage_sync.js]
 head = head.js head_sync.js
 skip-if = os == "android"
+[test_ext_storage_sync_crypto.js]
+skip-if = os == "android"
 [test_ext_topSites.js]
 skip-if = os == "android"
 [test_getAPILevelForWindow.js]
 [test_ext_legacy_extension_context.js]
 [test_ext_legacy_extension_embedding.js]
 [test_locale_converter.js]
 [test_locale_data.js]
 [test_native_messaging.js]