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

--HG--
rename : services/sync/tests/unit/test_extension_storage_crypto.js => toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_crypto.js
extra : rebase_source : f84c0810b6a447f2104f96c9dc9d6139743f269c
This commit is contained in:
Ethan Glasser-Camp 2017-02-13 15:06:02 -05:00
parent 34be31a2d7
commit 4f7afb05a6
6 changed files with 427 additions and 435 deletions

View File

@ -4,15 +4,12 @@
"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",
@ -105,189 +102,3 @@ ExtensionStorageTracker.prototype = {
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);
}
}

View File

@ -145,7 +145,6 @@ 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]

View File

@ -40,14 +40,14 @@ const {
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",
@ -64,10 +64,12 @@ 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",
@ -82,6 +84,204 @@ 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.
*
@ -181,236 +381,235 @@ const cryptoCollectionIdSchema = {
},
};
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()],
});
}),
getNewSalt() {
return btoa(CryptoUtils.generateRandomBytes(STORAGE_SYNC_CRYPTO_SALT_LENGTH_BYTES));
},
/**
* 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));
},
/**
* 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);
/**
* 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;
}),
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;
}),
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);
}),
/**
* 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(extensionId) {
return this.hashWithExtensionSalt(CommonUtils.encodeUTF8(extensionId), extensionId)
.then(hash => `ext-${hash}`);
},
/**
* 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}`);
},
/**
* 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?`);
}
/**
* 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"]
const hasher = Cc["@mozilla.org/security/hash;1"]
.createInstance(Ci.nsICryptoHash);
hasher.init(hasher.SHA256);
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",
});
}),
/**
* 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();
}),
};
const salt = atob(saltBase64);
const message = `${salt}\x00${value}`;
const hash = CryptoUtils.digestBytes(message, hasher);
return CommonUtils.encodeBase64URL(hash, false);
}),
/**
* An EncryptionRemoteTransformer for extension records.
* Retrieve the actual keyring from the crypto collection.
*
* 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.
* @returns {Promise<CollectionKeyManager>}
*/
CollectionKeyEncryptionRemoteTransformer = class extends EncryptionRemoteTransformer {
constructor(extensionId) {
super();
this.extensionId = extensionId;
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;
}),
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);
});
}
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();
}),
};
/**
* 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;
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.
*
@ -446,10 +645,7 @@ function cleanUpForContext(extension, context) {
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,
@ -457,16 +653,6 @@ const openCollection = Task.async(function* (extension, context) {
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.
@ -658,8 +844,6 @@ this.ExtensionStorageSync = {
* @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)) {
@ -716,8 +900,6 @@ this.ExtensionStorageSync = {
* 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,
@ -742,8 +924,6 @@ this.ExtensionStorageSync = {
* server.
*/
checkSyncKeyRing: Task.async(function* () {
ensureCryptoCollection();
yield this.updateKeyRingKB();
const cryptoKeyRecord = yield cryptoCollection.getKeyRingRecord();
@ -758,8 +938,6 @@ this.ExtensionStorageSync = {
}),
_syncKeyRing: Task.async(function* (cryptoKeyRecord) {
ensureCryptoCollection();
try {
// Try to sync using server_wins.
//

View File

@ -8,11 +8,13 @@ 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");
@ -20,7 +22,6 @@ 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) {

View File

@ -3,8 +3,10 @@
"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");
/**
@ -38,7 +40,6 @@ function* throwsGen(constraint, f) {
} else {
ok(constraint === message, debuggingMessage);
}
}
/**
@ -81,12 +82,12 @@ add_task(function* test_encryption_transformer_roundtrip() {
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);
});
});

View File

@ -63,6 +63,8 @@ skip-if = release_or_beta
[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]