--- a/toolkit/components/extensions/ExtensionStorageSync.jsm
+++ b/toolkit/components/extensions/ExtensionStorageSync.jsm
@@ -229,27 +229,74 @@ if (AppConstants.platform != "android")
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};
+ 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);
+ }),
+
+ /**
+ * 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: Task.async(function* (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);
+
+ const salt = atob(saltBase64);
+ const message = `${salt}\x00${CommonUtils.encodeUTF8(extensionId)}`;
+ const hash = CryptoUtils.digestBytes(message, hasher);
+ return `ext-${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) {
@@ -369,38 +416,16 @@ const openCollection = Task.async(functi
const coll = kinto.collection(collectionId, {
idSchema: storageSyncIdSchema,
remoteTransformers,
});
return coll;
});
/**
- * Hash an extension ID for a given user so that an attacker can't
- * identify the extensions a user has installed.
- *
- * @param {User} user
- * The user for whom to choose a collection to sync
- * an extension to.
- * @param {string} extensionId The extension ID to obfuscate.
- * @returns {string} A collection ID suitable for use to sync to.
- */
-function extensionIdToCollectionId(user, extensionId) {
- const userFingerprint = CryptoUtils.hkdf(user.uid, undefined,
- "identity.mozilla.com/picl/v1/chrome.storage.sync.collectionIds", 2 * 32);
- let data = new TextEncoder().encode(userFingerprint + extensionId);
- let hasher = Cc["@mozilla.org/security/hash;1"]
- .createInstance(Ci.nsICryptoHash);
- hasher.init(hasher.SHA256);
- hasher.update(data, data.length);
-
- return CommonUtils.bytesAsHex(hasher.finish(false));
-}
-
-/**
* 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?");
}
}
@@ -437,17 +462,17 @@ this.ExtensionStorageSync = {
sync: Task.async(function* (extension, collection) {
const signedInUser = yield this._fxaService.getSignedInUser();
if (!signedInUser) {
// FIXME: this should support syncing to self-hosted
log.info("User was not signed into FxA; cannot sync");
throw new Error("Not signed in to FxA");
}
- const collectionId = extensionIdToCollectionId(signedInUser, extension.id);
+ const collectionId = yield cryptoCollection.extensionIdToCollectionId(extension.id);
let syncResults;
try {
syncResults = yield this._syncCollection(collection, {
strategy: "client_wins",
collection: collectionId,
});
} catch (err) {
log.warn("Syncing failed", err);
--- a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js
@@ -8,17 +8,16 @@ do_get_profile(); // so we can use FxA
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,
idToKey,
- extensionIdToCollectionId,
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 */
@@ -431,27 +430,25 @@ function assertKeyRingKey(keyRing, exten
message);
}
// Tests using this ID will share keys in local storage, so be careful.
const defaultExtensionId = "{13bdde76-4dc7-11e6-9bdc-54ee758d6342}";
const defaultExtension = {id: defaultExtensionId};
const BORING_KB = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
-const ANOTHER_KB = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde0";
const loggedInUser = {
uid: "0123456789abcdef0123456789abcdef",
kB: BORING_KB,
oauthTokens: {
"sync:addon-storage": {
token: "some-access-token",
},
},
};
-const defaultCollectionId = extensionIdToCollectionId(loggedInUser, defaultExtensionId);
function uuid() {
const uuidgen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
return uuidgen.generateUUID().toString();
}
add_task(function* test_key_to_id() {
equal(keyToId("foo"), "key-foo");
@@ -474,27 +471,23 @@ add_task(function* test_key_to_id() {
equal(idToKey("key-_HI"), null);
equal(idToKey("key-_HI_"), null);
equal(idToKey("key-"), "");
equal(idToKey("key-1"), "1");
equal(idToKey("key-_2D_"), "-");
});
add_task(function* test_extension_id_to_collection_id() {
- const newKBUser = Object.assign(loggedInUser, {kB: ANOTHER_KB});
const extensionId = "{9419cce6-5435-11e6-84bf-54ee758d6342}";
- const extensionId2 = "{9419cce6-5435-11e6-84bf-54ee758d6343}";
+ // Fake a static keyring since the server doesn't exist.
+ const salt = "Scgx8RJ8Y0rxMGFYArUiKeawlW+0zJyFmtTDvro9qPo=";
+ yield cryptoCollection._setSalt(extensionId, salt);
- // "random" 32-char hex userid
- equal(extensionIdToCollectionId(loggedInUser, extensionId),
- "abf4e257dad0c89027f8f25bd196d4d69c100df375655a0c49f4cea7b791ea7d");
- equal(extensionIdToCollectionId(loggedInUser, extensionId),
- extensionIdToCollectionId(newKBUser, extensionId));
- equal(extensionIdToCollectionId(loggedInUser, extensionId2),
- "6584b0153336fb274912b31a3225c15a92b703cdc3adfe1917c1aa43122a52b8");
+ equal(yield cryptoCollection.extensionIdToCollectionId(extensionId),
+ "ext-0_QHA1P93_yJoj7ONisrR0lW6uN4PZ3Ii-rT-QOjtvo");
});
add_task(function* ensureCanSync_posts_new_keys() {
const extensionId = uuid();
yield* withContextAndServer(function* (context, server) {
yield* withSignedInUser(loggedInUser, function* () {
server.installCollection("storage-sync-crypto");
server.etag = 1000;
@@ -813,28 +806,29 @@ add_task(function* checkSyncKeyRing_over
});
add_task(function* checkSyncKeyRing_flushes_on_uuid_change() {
// If we can decrypt the record, but the UUID has changed, that
// means another client has wiped the server and reuploaded a
// keyring, so reset sync state and reupload everything.
const extensionId = uuid();
const extension = {id: extensionId};
- const collectionId = extensionIdToCollectionId(loggedInUser, extensionId);
const transformer = new KeyRingEncryptionRemoteTransformer();
yield* withSyncContext(function* (context) {
yield* withServer(function* (server) {
server.installCollection("storage-sync-crypto");
- server.installCollection(collectionId);
server.installDeleteBucket();
yield* withSignedInUser(loggedInUser, function* () {
yield cryptoCollection._clear();
- // Do an `ensureCanSync` to get access to keys.
+ // Do an `ensureCanSync` to get access to keys and salt.
let collectionKeys = yield ExtensionStorageSync.ensureCanSync([extensionId]);
+ const collectionId = yield cryptoCollection.extensionIdToCollectionId(extensionId);
+ server.installCollection(collectionId);
+
ok(collectionKeys.hasKeysFor([extensionId]),
`ensureCanSync should always return a keyring that has a key for ${extensionId}`);
const extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64;
// Set something to make sure that it gets re-uploaded when
// uuid changes.
yield ExtensionStorageSync.set(extension, {"my-key": 5}, context);
yield ExtensionStorageSync.syncAll();
@@ -911,30 +905,30 @@ add_task(function* checkSyncKeyRing_flus
"extension data should have a data attribute corresponding to the extension data value");
});
});
});
});
add_task(function* test_storage_sync_pulls_changes() {
const extensionId = defaultExtensionId;
- const collectionId = defaultCollectionId;
const extension = defaultExtension;
yield* withContextAndServer(function* (context, server) {
yield* withSignedInUser(loggedInUser, function* () {
let transformer = new CollectionKeyEncryptionRemoteTransformer(extensionId);
- server.installCollection(collectionId);
server.installCollection("storage-sync-crypto");
let calls = [];
yield ExtensionStorageSync.addOnChangedListener(extension, function() {
calls.push(arguments);
}, context);
yield ExtensionStorageSync.ensureCanSync([extensionId]);
+ const collectionId = yield cryptoCollection.extensionIdToCollectionId(extensionId);
+ server.installCollection(collectionId);
yield server.encryptAndAddRecord(transformer, collectionId, {
"id": "key-remote_2D_key",
"key": "remote-key",
"data": 6,
});
yield ExtensionStorageSync.syncAll();
const remoteValue = (yield ExtensionStorageSync.get(extension, "remote-key", context))["remote-key"];
@@ -969,18 +963,20 @@ add_task(function* test_storage_sync_pul
equal(calls.length, 1,
"syncing calls on-changed listener on update");
deepEqual(calls[0][0], {"remote-key": {oldValue: 6, newValue: 7}});
});
});
});
add_task(function* test_storage_sync_pushes_changes() {
+ // FIXME: This test relies on the fact that previous tests pushed
+ // keys and salts for the default extension ID
const extensionId = defaultExtensionId;
- const collectionId = defaultCollectionId;
+ const collectionId = yield cryptoCollection.extensionIdToCollectionId(extensionId);
const extension = defaultExtension;
yield* withContextAndServer(function* (context, server) {
yield* withSignedInUser(loggedInUser, function* () {
let transformer = new CollectionKeyEncryptionRemoteTransformer(extensionId);
server.installCollection(collectionId);
server.installCollection("storage-sync-crypto");
server.etag = 1000;
@@ -993,16 +989,17 @@ add_task(function* test_storage_sync_pus
}, context);
yield ExtensionStorageSync.syncAll();
const localValue = (yield ExtensionStorageSync.get(extension, "my-key", context))["my-key"];
equal(localValue, 5,
"pushing an ExtensionStorageSync value shouldn't change local value");
let posts = server.getPosts();
+ // FIXME: Keys were pushed in a previous test
equal(posts.length, 1,
"pushing a value should cause a post to the server");
const post = posts[0];
assertPostedNewRecord(post);
equal(post.path, collectionRecordsPath(collectionId) + "/key-my_2D_key",
"pushing a value should have a path corresponding to its id");
const encrypted = post.body.data;
@@ -1043,17 +1040,17 @@ add_task(function* test_storage_sync_pus
"pushing an updated value should not have any plaintext visible");
equal(updateEncrypted.id, "key-my_2D_key",
"pushing an updated value should maintain the same ID");
});
});
});
add_task(function* test_storage_sync_pulls_deletes() {
- const collectionId = defaultCollectionId;
+ const collectionId = yield cryptoCollection.extensionIdToCollectionId(defaultExtensionId);
const extension = defaultExtension;
yield* withContextAndServer(function* (context, server) {
yield* withSignedInUser(loggedInUser, function* () {
server.installCollection(collectionId);
server.installCollection("storage-sync-crypto");
yield ExtensionStorageSync.set(extension, {"my-key": 5}, context);
yield ExtensionStorageSync.syncAll();
@@ -1089,19 +1086,20 @@ add_task(function* test_storage_sync_pul
equal(calls.length, 0,
"syncing again shouldn't call on-changed listener");
});
});
});
add_task(function* test_storage_sync_pushes_deletes() {
const extensionId = uuid();
- const collectionId = extensionIdToCollectionId(loggedInUser, extensionId);
const extension = {id: extensionId};
yield cryptoCollection._clear();
+ yield cryptoCollection._setSalt(extensionId, cryptoCollection.getNewSalt());
+ const collectionId = yield cryptoCollection.extensionIdToCollectionId(extensionId);
yield* withContextAndServer(function* (context, server) {
yield* withSignedInUser(loggedInUser, function* () {
server.installCollection(collectionId);
server.installCollection("storage-sync-crypto");
server.etag = 1000;
yield ExtensionStorageSync.set(extension, {"my-key": 5}, context);