--- 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/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.
//