--- 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]