Add crypto, including tests, r?markh draft
authorEthan Glasser-Camp <eglassercamp@mozilla.com>
Wed, 24 Aug 2016 14:12:51 -0400
changeset 405197 56d268732e2c7d04764a4a4a586e7d1098508bff
parent 405196 0cd5905bb941b7e8592761b9d00f860ad00f6f63
child 405198 a615871b143e3bdeb54401b8c96a350675bcb37e
push id27431
push usereglassercamp@mozilla.com
push dateThu, 25 Aug 2016 02:36:08 +0000
reviewersmarkh
milestone50.0a1
Add crypto, including tests, r?markh MozReview-Commit-ID: Jq8QRoNtPwb
services/sync/modules/engines/extension-storage.js
services/sync/tests/unit/test_extension_storage_crypto.js
services/sync/tests/unit/xpcshell.ini
--- a/services/sync/modules/engines/extension-storage.js
+++ b/services/sync/modules/engines/extension-storage.js
@@ -1,21 +1,27 @@
 /* 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/. */
 
-this.EXPORTED_SYMBOLS = ['ExtensionStorageEngine'];
+this.EXPORTED_SYMBOLS = ['ExtensionStorageEngine', 'EncryptionRemoteTransformer',
+                         'UserKeyEncryptionRemoteTransformer'];
 
 var {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");
-Cu.import("resource://gre/modules/ExtensionStorageSync.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorageSync",
+                                  "resource://gre/modules/ExtensionStorageSync.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
+                                  "resource://gre/modules/FxAccounts.jsm");
 
 /**
  * The Engine that manages syncing for the web extension "storage"
  * API, and in particular ext.storage.sync.
  *
  * ext.storage.sync is implemented using Kinto, so it has mechanisms
  * for syncing that we do not need to integrate in the Firefox Sync
  * framework, so this is something of a stub.
@@ -64,8 +70,120 @@ ExtensionStorageTracker.prototype = {
       return;
     }
 
     // Single adds, removes and changes are not so important on their
     // own, so let's just increment score a bit.
     this.score += SCORE_INCREMENT_MEDIUM;
   },
 };
+
+/**
+ * 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.
+ */
+this.EncryptionRemoteTransformer = function() {
+}
+EncryptionRemoteTransformer.prototype = {
+  _fxaService: fxAccounts,   // you can inject this
+  encode(record) {
+    return this.getKeys().then(keyBundle => {
+      if (record.ciphertext) {
+        throw new Error("Attempt to reencrypt??");
+      }
+      let IV = Svc.Crypto.generateRandomIV();
+      let id = record.id;
+      let ciphertext = Svc.Crypto.encrypt(JSON.stringify(record),
+                                          keyBundle.encryptionKeyB64, IV);
+      let hmac = ciphertextHMAC(keyBundle, id, IV, ciphertext);
+      const encryptedResult = {ciphertext, IV, hmac, id};
+      if (record.hasOwnProperty("last_modified")) {
+        encryptedResult.last_modified = record.last_modified;
+      }
+      return encryptedResult;
+    });
+  },
+  decode(record) {
+    return this.getKeys().then(keyBundle => {
+      if (!record.ciphertext) {
+        throw new Error("No ciphertext: nothing to decrypt?");
+      }
+      // 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.");
+      }
+
+      // Verify that the encrypted id matches the requested record's id.
+      // This should always be true, because we compute the HMAC over
+      // the original record's ID, and that was verified already (above).
+      if (jsonResult.id != record.id) {
+        throw new Error("Record id mismatch: " + jsonResult.id + " != " + record.id);
+      }
+
+      if (record.hasOwnProperty("last_modified")) {
+        jsonResult.last_modified = record.last_modified;
+      }
+
+      return jsonResult;
+    });
+  },
+  /**
+   * Retrieve keys to use during encryption.
+   *
+   * Returns a Promise<KeyBundle>.
+   */
+  getKeys() {
+    throw new Error("override getKeys in a subclass");
+  },
+};
+
+/**
+ * An EncryptionRemoteTransformer that provides a keybundle derived
+ * from the user's kB.
+ */
+this.UserKeyEncryptionRemoteTransformer = function() {
+  EncryptionRemoteTransformer.call(this);
+};
+UserKeyEncryptionRemoteTransformer.prototype = {
+  __proto__: EncryptionRemoteTransformer.prototype,
+  getKeys() {
+    return this._fxaService.getSignedInUser().then(user => {
+      // FIXME: we should permit this if the user is self-hosting
+      // their storage
+      if (!user) {
+        return Promise.reject(Error("user isn't signed in to FxA; can't sync"));
+      }
+
+      if (!user.kB) {
+        return Promise.reject(Error("user doesn't have kB"));
+      }
+      let kB = Utils.hexToBytes(user.kB);
+
+      let keyMaterial = CryptoUtils.hkdf(kB, undefined,
+                                         "storage.sync keyring key", 2*32);
+      let bundle = new BulkKeyBundle();
+      // [encryptionKey, hmacKey]
+      bundle.keyPair = [keyMaterial.slice(0, 32), keyMaterial.slice(32, 64)];
+      return bundle;
+    });
+  }
+};
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/unit/test_extension_storage_crypto.js
@@ -0,0 +1,85 @@
+"use strict";
+
+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.
+ */
+function* throwsGen(constraint, f) {
+  let threw = false;
+  let exception;
+  try {
+    yield* f();
+  }
+  catch (e) {
+    threw = true;
+    exception = e;
+  }
+
+  ok(threw);
+
+  const debuggingMessage = `got ${exception}, expected ${constraint}`;
+  let message = exception;
+  if (typeof exception === "object") {
+    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.
+ */
+function StaticKeyEncryptionRemoteTransformer(keyBundle) {
+  EncryptionRemoteTransformer.call(this);
+  this.keyBundle = keyBundle;
+}
+StaticKeyEncryptionRemoteTransformer.prototype = {
+  __proto__: EncryptionRemoteTransformer.prototype,
+  getKeys() {
+    return Promise.resolve(this.keyBundle);
+  }
+};
+const BORING_KB = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+const STRETCHED_KEY = CryptoUtils.hkdf(BORING_KB, undefined, `testing storage.sync encryption`, 2*32);
+const KEY_BUNDLE = {
+  sha256HMACHasher: Utils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256, Utils.makeHMACKey(STRETCHED_KEY.slice(0, 32))),
+  encryptionKeyB64: btoa(STRETCHED_KEY.slice(32, 64)),
+};
+const transformer = new StaticKeyEncryptionRemoteTransformer(KEY_BUNDLE);
+
+add_task(function* test_encryption_transformer_roundtrip() {
+  const POSSIBLE_DATAS = [
+    "string",
+    2,          // number
+    [1, 2, 3],  // array
+    {key: "value"}, // object
+  ];
+
+  for (let data of POSSIBLE_DATAS) {
+    const record = {data: data, id: "key-some_2D_key", key: "some-key"};
+
+    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 transformer.decode(tamperedHMAC);
+  });
+
+  const tamperedIV = Object.assign({}, encryptedRecord, {IV: "aaaaaaaaaaaaaaaaaaaaaa=="});
+  yield* throwsGen(Utils.isHMACMismatch, function*() {
+    yield transformer.decode(tamperedIV);
+  });
+});
--- a/services/sync/tests/unit/xpcshell.ini
+++ b/services/sync/tests/unit/xpcshell.ini
@@ -154,16 +154,17 @@ tags = addons
 [test_bookmark_smart_bookmarks.js]
 [test_bookmark_store.js]
 # Too many intermittent "ASSERTION: thread pool wasn't shutdown: '!mPool'" (bug 804479)
 skip-if = debug
 [test_bookmark_tracker.js]
 [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]