summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/ExtensionStorageSyncKinto.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:44:51 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:44:51 +0000
commit9e3c08db40b8916968b9f30096c7be3f00ce9647 (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/components/extensions/ExtensionStorageSyncKinto.sys.mjs
parentInitial commit. (diff)
downloadthunderbird-upstream.tar.xz
thunderbird-upstream.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/extensions/ExtensionStorageSyncKinto.sys.mjs')
-rw-r--r--toolkit/components/extensions/ExtensionStorageSyncKinto.sys.mjs1378
1 files changed, 1378 insertions, 0 deletions
diff --git a/toolkit/components/extensions/ExtensionStorageSyncKinto.sys.mjs b/toolkit/components/extensions/ExtensionStorageSyncKinto.sys.mjs
new file mode 100644
index 0000000000..d473813ea5
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionStorageSyncKinto.sys.mjs
@@ -0,0 +1,1378 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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/. */
+
+// TODO:
+// * find out how the Chrome implementation deals with conflicts
+
+// TODO bug 1637465: Remove the Kinto-based storage implementation.
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const KINTO_PROD_SERVER_URL =
+ "https://webextensions.settings.services.mozilla.com/v1";
+const KINTO_DEFAULT_SERVER_URL = KINTO_PROD_SERVER_URL;
+
+const STORAGE_SYNC_ENABLED_PREF = "webextensions.storage.sync.enabled";
+const STORAGE_SYNC_SERVER_URL_PREF = "webextensions.storage.sync.serverURL";
+const STORAGE_SYNC_SCOPE = "sync:addon_storage";
+const STORAGE_SYNC_CRYPTO_COLLECTION_NAME = "storage-sync-crypto";
+const STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID = "keys";
+const STORAGE_SYNC_CRYPTO_SALT_LENGTH_BYTES = 32;
+const FXA_OAUTH_OPTIONS = {
+ scope: STORAGE_SYNC_SCOPE,
+};
+// Default is 5sec, which seems a bit aggressive on the open internet
+const KINTO_REQUEST_TIMEOUT = 30000;
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ BulkKeyBundle: "resource://services-sync/keys.sys.mjs",
+ CollectionKeyManager: "resource://services-sync/record.sys.mjs",
+ CommonUtils: "resource://services-common/utils.sys.mjs",
+ CryptoUtils: "resource://services-crypto/utils.sys.mjs",
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs",
+ FirefoxAdapter: "resource://services-common/kinto-storage-adapter.sys.mjs",
+ Observers: "resource://services-common/observers.sys.mjs",
+ Utils: "resource://services-sync/util.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ KintoHttpClient: "resource://services-common/kinto-http-client.js",
+ Kinto: "resource://services-common/kinto-offline-client.js",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "fxAccounts", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ ).getFxAccountsSingleton();
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "prefPermitsStorageSync",
+ STORAGE_SYNC_ENABLED_PREF,
+ true
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "prefStorageSyncServerURL",
+ STORAGE_SYNC_SERVER_URL_PREF,
+ KINTO_DEFAULT_SERVER_URL
+);
+XPCOMUtils.defineLazyGetter(lazy, "WeaveCrypto", function () {
+ let { WeaveCrypto } = ChromeUtils.importESModule(
+ "resource://services-crypto/WeaveCrypto.sys.mjs"
+ );
+ return new WeaveCrypto();
+});
+
+const { DefaultMap } = ExtensionUtils;
+
+// Map of Extensions to Set<Contexts> to track contexts that are still
+// "live" and use storage.sync.
+const extensionContexts = new DefaultMap(() => new Set());
+// Borrow logger from Sync.
+const log = Log.repository.getLogger("Sync.Engine.Extension-Storage");
+
+// A global that is fxAccounts, or null if (as on android) fxAccounts
+// isn't available.
+let _fxaService = null;
+if (AppConstants.platform != "android") {
+ _fxaService = lazy.fxAccounts;
+}
+
+class ServerKeyringDeleted extends Error {
+ constructor() {
+ super(
+ "server keyring appears to have disappeared; we were called to decrypt null"
+ );
+ }
+}
+
+/**
+ * Check for FXA and throw an exception if we don't have access.
+ *
+ * @param {object} fxAccounts The reference we were hoping to use to
+ * access FxA
+ * @param {string} action The thing we were doing when we decided to
+ * see if we had access to FxA
+ */
+function throwIfNoFxA(fxAccounts, action) {
+ if (!fxAccounts) {
+ throw new Error(
+ `${action} is impossible because FXAccounts is not available; are you on Android?`
+ );
+ }
+}
+
+// Global ExtensionStorageSyncKinto instance that extensions and Fx Sync use.
+// On Android, because there's no FXAccounts instance, any syncing
+// operations will fail.
+export var extensionStorageSyncKinto = null;
+
+/**
+ * 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
+ */
+async function ciphertextHMAC(keyBundle, id, IV, ciphertext) {
+ const hmacKey = lazy.CommonUtils.byteStringToArrayBuffer(keyBundle.hmacKey);
+ const encoder = new TextEncoder();
+ const data = encoder.encode(id + IV + ciphertext);
+ const hmac = await lazy.CryptoUtils.hmac("SHA-256", hmacKey, data);
+ return lazy.CommonUtils.bytesAsHex(
+ lazy.CommonUtils.arrayBufferToByteString(hmac)
+ );
+}
+
+/**
+ * Get the current user's hashed kB.
+ *
+ * @param {FXAccounts} fxaService The service to use to get the
+ * current user.
+ * @returns {string} sha256 of the user's kB as a hex string
+ */
+const getKBHash = async function (fxaService) {
+ const key = await fxaService.keys.getKeyForScope(STORAGE_SYNC_SCOPE);
+ return fxaService.keys.kidAsHex(key);
+};
+
+/**
+ * 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 {
+ async encode(record) {
+ const keyBundle = await this.getKeys();
+ if (record.ciphertext) {
+ throw new Error("Attempt to reencrypt??");
+ }
+ let id = await this.getEncodedRecordId(record);
+ if (!id) {
+ throw new Error("Record ID is missing or invalid");
+ }
+
+ let IV = lazy.WeaveCrypto.generateRandomIV();
+ let ciphertext = await lazy.WeaveCrypto.encrypt(
+ JSON.stringify(record),
+ keyBundle.encryptionKeyB64,
+ IV
+ );
+ let hmac = await 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;
+ }
+
+ async decode(record) {
+ 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 = await this.getKeys();
+ // Authenticate the encrypted blob with the expected HMAC
+ let computedHMAC = await ciphertextHMAC(
+ keyBundle,
+ record.id,
+ record.IV,
+ record.ciphertext
+ );
+
+ if (computedHMAC != record.hmac) {
+ lazy.Utils.throwHMACMismatch(record.hmac, computedHMAC);
+ }
+
+ // Handle invalid data here. Elsewhere we assume that cleartext is an object.
+ let cleartext = await lazy.WeaveCrypto.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);
+ }
+}
+
+/**
+ * An EncryptionRemoteTransformer that provides a keybundle derived
+ * from the user's kB, suitable for encrypting a keyring.
+ */
+class KeyRingEncryptionRemoteTransformer extends EncryptionRemoteTransformer {
+ constructor(fxaService) {
+ super();
+ this._fxaService = fxaService;
+ }
+
+ getKeys() {
+ throwIfNoFxA(this._fxaService, "encrypting chrome.storage.sync records");
+ const self = this;
+ return (async function () {
+ let key = await self._fxaService.keys.getKeyForScope(STORAGE_SYNC_SCOPE);
+ return lazy.BulkKeyBundle.fromJWK(key);
+ })();
+ }
+ // 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.
+ async encode(record) {
+ const encoded = await super.encode(record);
+ encoded.kbHash = record.kbHash;
+ return encoded;
+ }
+
+ async decode(record) {
+ try {
+ return await super.decode(record);
+ } catch (e) {
+ if (lazy.Utils.isHMACMismatch(e)) {
+ const currentKBHash = await getKBHash(this._fxaService);
+ 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 Promise that centralizes initialization of ExtensionStorageSyncKinto.
+ *
+ * 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.
+ * - kinto: a KintoBase object, suitable for using in Firefox. All
+ * collections in this database will use the same Sqlite connection.
+ *
+ * @returns {Promise<object>}
+ */
+async function storageSyncInit() {
+ // Memoize the result to share the connection.
+ if (storageSyncInit.promise === undefined) {
+ const path = "storage-sync.sqlite";
+ storageSyncInit.promise = lazy.FirefoxAdapter.openConnection({ path })
+ .then(connection => {
+ return {
+ connection,
+ kinto: new lazy.Kinto({
+ adapter: lazy.FirefoxAdapter,
+ adapterOptions: { sqliteHandle: connection },
+ timeout: KINTO_REQUEST_TIMEOUT,
+ retry: 0,
+ }),
+ };
+ })
+ .catch(e => {
+ // Ensure one failure doesn't break us forever.
+ Cu.reportError(e);
+ storageSyncInit.promise = undefined;
+ throw e;
+ });
+ }
+ return storageSyncInit.promise;
+}
+
+// Kinto record IDs have two conditions:
+//
+// - They must contain only ASCII alphanumerics plus - and _. To fix
+// this, we encode all non-letters using _C_, where C is the
+// percent-encoded character, so space becomes _20_
+// and underscore becomes _5F_.
+//
+// - They must start with an ASCII letter. To ensure this, we prefix
+// all keys with "key-".
+function keyToId(key) {
+ function escapeChar(match) {
+ return "_" + match.codePointAt(0).toString(16).toUpperCase() + "_";
+ }
+ return "key-" + key.replace(/[^a-zA-Z0-9]/g, escapeChar);
+}
+
+// Convert a Kinto ID back into a chrome.storage key.
+// Returns null if a key couldn't be parsed.
+function idToKey(id) {
+ function unescapeNumber(match, group1) {
+ return String.fromCodePoint(parseInt(group1, 16));
+ }
+ // An escaped ID should match this regex.
+ // An escaped ID should consist of only letters and numbers, plus
+ // code points escaped as _[0-9a-f]+_.
+ const ESCAPED_ID_FORMAT = /^(?:[a-zA-Z0-9]|_[0-9A-F]+_)*$/;
+
+ if (!id.startsWith("key-")) {
+ return null;
+ }
+ const unprefixed = id.slice(4);
+ // Verify that the ID is the correct format.
+ if (!ESCAPED_ID_FORMAT.test(unprefixed)) {
+ return null;
+ }
+ return unprefixed.replace(/_([0-9A-F]+)_/g, unescapeNumber);
+}
+
+// An "id schema" used to validate Kinto IDs and generate new ones.
+const storageSyncIdSchema = {
+ // We should never generate IDs; chrome.storage only acts as a
+ // key-value store, so we should always have a key.
+ generate() {
+ throw new Error("cannot generate IDs");
+ },
+
+ // See keyToId and idToKey for more details.
+ validate(id) {
+ return idToKey(id) !== null;
+ },
+};
+
+// An "id schema" used for the system collection, which doesn't
+// require validation or generation of IDs.
+const cryptoCollectionIdSchema = {
+ generate() {
+ throw new Error("cannot generate IDs for system collection");
+ },
+
+ validate(id) {
+ return true;
+ },
+};
+
+/**
+ * Wrapper around the crypto collection providing some handy utilities.
+ */
+class CryptoCollection {
+ constructor(fxaService) {
+ this._fxaService = fxaService;
+ }
+
+ async getCollection() {
+ throwIfNoFxA(this._fxaService, "tried to access cryptoCollection");
+ const { kinto } = await storageSyncInit();
+ return kinto.collection(STORAGE_SYNC_CRYPTO_COLLECTION_NAME, {
+ idSchema: cryptoCollectionIdSchema,
+ remoteTransformers: [
+ new KeyRingEncryptionRemoteTransformer(this._fxaService),
+ ],
+ });
+ }
+
+ /**
+ * Generate a new salt for use in hashing extension and record
+ * IDs.
+ *
+ * @returns {string} A base64-encoded string of the salt
+ */
+ getNewSalt() {
+ return btoa(
+ lazy.CryptoUtils.generateRandomBytesLegacy(
+ 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>}
+ */
+ async getKeyRingRecord() {
+ const collection = await this.getCollection();
+ const cryptoKeyRecord = await 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 uuid = Services.uuid.generateUUID().toString();
+ data = { uuid, id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID };
+ }
+ return data;
+ }
+
+ async getSalts() {
+ const cryptoKeyRecord = await this.getKeyRingRecord();
+ return cryptoKeyRecord && cryptoKeyRecord.salts;
+ }
+
+ /**
+ * Used for testing with a known salt.
+ *
+ * @param {string} extensionId The extension ID for which to set a
+ * salt.
+ * @param {string} salt The salt to use for this extension, as a
+ * base64-encoded salt.
+ */
+ async _setSalt(extensionId, salt) {
+ const cryptoKeyRecord = await this.getKeyRingRecord();
+ cryptoKeyRecord.salts = cryptoKeyRecord.salts || {};
+ cryptoKeyRecord.salts[extensionId] = salt;
+ await 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(
+ lazy.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.
+ */
+ async hashWithExtensionSalt(value, extensionId) {
+ const salts = await 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${value}`;
+ const hash = lazy.CryptoUtils.digestBytes(message, hasher);
+ return lazy.CommonUtils.encodeBase64URL(hash, false);
+ }
+
+ /**
+ * Retrieve the actual keyring from the crypto collection.
+ *
+ * @returns {Promise<CollectionKeyManager>}
+ */
+ async getKeyRing() {
+ const cryptoKeyRecord = await this.getKeyRingRecord();
+ const collectionKeys = new lazy.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.
+ await collectionKeys.generateDefaultKey();
+ }
+ // Pass through uuid field so that we can save it if we need to.
+ collectionKeys.uuid = cryptoKeyRecord.uuid;
+ return collectionKeys;
+ }
+
+ async updateKBHash(kbHash) {
+ const coll = await this.getCollection();
+ await coll.update(
+ { id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID, kbHash: kbHash },
+ { patch: true }
+ );
+ }
+
+ async upsert(record) {
+ const collection = await this.getCollection();
+ await collection.upsert(record);
+ }
+
+ async sync(extensionStorageSyncKinto) {
+ const collection = await this.getCollection();
+ return extensionStorageSyncKinto._syncCollection(collection, {
+ strategy: "server_wins",
+ });
+ }
+
+ /**
+ * Reset sync status for ALL collections by directly
+ * accessing the FirefoxAdapter.
+ */
+ async resetSyncStatus() {
+ const coll = await this.getCollection();
+ await coll.db.resetSyncStatus();
+ }
+
+ // Used only for testing.
+ async _clear() {
+ const collection = await this.getCollection();
+ await 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(cryptoCollection, keyring, extensionId) {
+ super();
+ this.cryptoCollection = cryptoCollection;
+ this.keyring = keyring;
+ this.extensionId = extensionId;
+ }
+
+ async getKeys() {
+ if (!this.keyring.hasKeysFor([this.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 this.keyring.keyForCollection(this.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 = lazy.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 this.cryptoCollection
+ .hashWithExtensionSalt(id, this.extensionId)
+ .then(hash => `id-${hash}`);
+ }
+};
+
+/**
+ * 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.
+ */
+function cleanUpForContext(extension, context) {
+ const contexts = extensionContexts.get(extension);
+ contexts.delete(context);
+ if (contexts.size === 0) {
+ // Nobody else is using this collection. Clean up.
+ extensionContexts.delete(extension);
+ }
+}
+
+/**
+ * Generate a promise that produces the Collection for an extension.
+ *
+ * @param {Extension} extension
+ * The extension whose collection needs to
+ * be opened.
+ * @param {object} options
+ * Options to be passed to the call to `.collection()`.
+ * @returns {Promise<Collection>}
+ */
+const openCollection = async function (extension, options = {}) {
+ let collectionId = extension.id;
+ const { kinto } = await storageSyncInit();
+ const coll = kinto.collection(collectionId, {
+ ...options,
+ idSchema: storageSyncIdSchema,
+ });
+ return coll;
+};
+
+export class ExtensionStorageSyncKinto {
+ /**
+ * @param {FXAccounts} fxaService (Optional) If not
+ * present, trying to sync will fail.
+ */
+ constructor(fxaService) {
+ this._fxaService = fxaService;
+ this.cryptoCollection = new CryptoCollection(fxaService);
+ this.listeners = new WeakMap();
+ }
+
+ /**
+ * Get a set of extensions to sync (including the ones with an
+ * active extension context that used the storage.sync API and
+ * the extensions that are enabled and have been synced before).
+ *
+ * @returns {Promise<Set<Extension>>}
+ * A promise which resolves to the set of the extensions to sync.
+ */
+ async getExtensions() {
+ // Start from the set of the extensions with an active
+ // context that used the storage.sync APIs.
+ const extensions = new Set(extensionContexts.keys());
+
+ const allEnabledExtensions = await lazy.AddonManager.getAddonsByTypes([
+ "extension",
+ ]);
+
+ // Get the existing extension collections salts.
+ const keysRecord = await this.cryptoCollection.getKeyRingRecord();
+
+ // Add any enabled extensions that have been synced before.
+ for (const addon of allEnabledExtensions) {
+ if (this.hasSaltsFor(keysRecord, [addon.id])) {
+ const policy = WebExtensionPolicy.getByID(addon.id);
+ if (policy && policy.extension) {
+ extensions.add(policy.extension);
+ }
+ }
+ }
+
+ return extensions;
+ }
+
+ async syncAll() {
+ const extensions = await this.getExtensions();
+ const extIds = Array.from(extensions, extension => extension.id);
+ log.debug(`Syncing extension settings for ${JSON.stringify(extIds)}`);
+ if (!extIds.length) {
+ // No extensions to sync. Get out.
+ return;
+ }
+ await this.ensureCanSync(extIds);
+ await this.checkSyncKeyRing();
+ const keyring = await this.cryptoCollection.getKeyRing();
+ const promises = Array.from(extensions, extension => {
+ const remoteTransformers = [
+ new CollectionKeyEncryptionRemoteTransformer(
+ this.cryptoCollection,
+ keyring,
+ extension.id
+ ),
+ ];
+ return openCollection(extension, { remoteTransformers }).then(coll => {
+ return this.sync(extension, coll);
+ });
+ });
+ await Promise.all(promises);
+ }
+
+ async sync(extension, collection) {
+ throwIfNoFxA(this._fxaService, "syncing chrome.storage.sync");
+ const isSignedIn = !!(await this._fxaService.getSignedInUser());
+ if (!isSignedIn) {
+ // 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 = await this.cryptoCollection.extensionIdToCollectionId(
+ extension.id
+ );
+ let syncResults;
+ try {
+ syncResults = await this._syncCollection(collection, {
+ strategy: "client_wins",
+ collection: collectionId,
+ });
+ } catch (err) {
+ log.warn("Syncing failed", err);
+ throw err;
+ }
+
+ let changes = {};
+ for (const record of syncResults.created) {
+ changes[record.key] = {
+ newValue: record.data,
+ };
+ }
+ for (const record of syncResults.updated) {
+ // N.B. It's safe to just pick old.key because it's not
+ // possible to "rename" a record in the storage.sync API.
+ const key = record.old.key;
+ changes[key] = {
+ oldValue: record.old.data,
+ newValue: record.new.data,
+ };
+ }
+ for (const record of syncResults.deleted) {
+ changes[record.key] = {
+ oldValue: record.data,
+ };
+ }
+ for (const resolution of syncResults.resolved) {
+ // FIXME: We can't send a "changed" notification because
+ // kinto.js only provides the newly-resolved value. But should
+ // we even send a notification? We use CLIENT_WINS so nothing
+ // has really "changed" on this end. (The change will come on
+ // the other end when it pulls down the update, which is handled
+ // by the "updated" case above.) If we are going to send a
+ // notification, what best values for "old" and "new"? This
+ // might violate client code's assumptions, since from their
+ // perspective, we were in state L, but this diff is from R ->
+ // L.
+ const accepted = resolution.accepted;
+ changes[accepted.key] = {
+ newValue: accepted.data,
+ };
+ }
+ if (Object.keys(changes).length) {
+ this.notifyListeners(extension, changes);
+ }
+ log.info(`Successfully synced '${collection.name}'`);
+ }
+
+ /**
+ * Utility function that handles the common stuff about syncing all
+ * Kinto collections (including "meta" collections like the crypto
+ * one).
+ *
+ * @param {Collection} collection
+ * @param {object} options
+ * Additional options to be passed to sync().
+ * @returns {Promise<SyncResultObject>}
+ */
+ _syncCollection(collection, options) {
+ // FIXME: this should support syncing to self-hosted
+ return this._requestWithToken(
+ `Syncing ${collection.name}`,
+ function (token) {
+ const allOptions = Object.assign(
+ {},
+ {
+ remote: lazy.prefStorageSyncServerURL,
+ headers: {
+ Authorization: "Bearer " + token,
+ },
+ },
+ options
+ );
+
+ return collection.sync(allOptions);
+ }
+ );
+ }
+
+ // Make a Kinto request with a current FxA token.
+ // If the response indicates that the token might have expired,
+ // retry the request.
+ async _requestWithToken(description, f) {
+ throwIfNoFxA(
+ this._fxaService,
+ "making remote requests from chrome.storage.sync"
+ );
+ const fxaToken = await this._fxaService.getOAuthToken(FXA_OAUTH_OPTIONS);
+ try {
+ return await f(fxaToken);
+ } catch (e) {
+ if (e && e.response && e.response.status == 401) {
+ // Our token might have expired. Refresh and retry.
+ log.info("Token might have expired");
+ await this._fxaService.removeCachedOAuthToken({ token: fxaToken });
+ const newToken = await this._fxaService.getOAuthToken(
+ FXA_OAUTH_OPTIONS
+ );
+
+ // If this fails too, let it go.
+ return f(newToken);
+ }
+ // Otherwise, we don't know how to handle this error, so just reraise.
+ log.error(`${description}: request failed`, e);
+ throw e;
+ }
+ }
+
+ /**
+ * Helper similar to _syncCollection, but for deleting the user's bucket.
+ *
+ * @returns {Promise<void>}
+ */
+ _deleteBucket() {
+ log.error("Deleting default bucket and everything in it");
+ return this._requestWithToken("Clearing server", function (token) {
+ const headers = { Authorization: "Bearer " + token };
+ const kintoHttp = new lazy.KintoHttpClient(
+ lazy.prefStorageSyncServerURL,
+ {
+ headers: headers,
+ timeout: KINTO_REQUEST_TIMEOUT,
+ }
+ );
+ return kintoHttp.deleteBucket("default");
+ });
+ }
+
+ async ensureSaltsFor(keysRecord, extIds) {
+ const newSalts = Object.assign({}, keysRecord.salts);
+ for (let collectionId of extIds) {
+ if (newSalts[collectionId]) {
+ continue;
+ }
+
+ newSalts[collectionId] = this.cryptoCollection.getNewSalt();
+ }
+
+ return newSalts;
+ }
+
+ /**
+ * Check whether the keys record (provided) already has salts for
+ * all the extensions given in extIds.
+ *
+ * @param {object} keysRecord A previously-retrieved keys record.
+ * @param {Array<string>} extIds The IDs of the extensions which
+ * need salts.
+ * @returns {boolean}
+ */
+ hasSaltsFor(keysRecord, extIds) {
+ if (!keysRecord.salts) {
+ return false;
+ }
+
+ for (let collectionId of extIds) {
+ if (!keysRecord.salts[collectionId]) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Recursive promise that terminates when our local collectionKeys,
+ * 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>}
+ */
+ async ensureCanSync(extIds) {
+ const keysRecord = await this.cryptoCollection.getKeyRingRecord();
+ const collectionKeys = await this.cryptoCollection.getKeyRing();
+ if (
+ collectionKeys.hasKeysFor(extIds) &&
+ this.hasSaltsFor(keysRecord, extIds)
+ ) {
+ return collectionKeys;
+ }
+
+ log.info(`Need to create keys and/or salts for ${JSON.stringify(extIds)}`);
+ const kbHash = await getKBHash(this._fxaService);
+ const newKeys = await collectionKeys.ensureKeysFor(extIds);
+ const newSalts = await this.ensureSaltsFor(keysRecord, extIds);
+ const newRecord = {
+ id: STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID,
+ keys: newKeys.asWBO().cleartext,
+ salts: newSalts,
+ uuid: collectionKeys.uuid,
+ // Add a field for the current kB hash.
+ kbHash: kbHash,
+ };
+ await this.cryptoCollection.upsert(newRecord);
+ const result = await this._syncKeyRing(newRecord);
+ if (result.resolved.length) {
+ // We had a conflict which was automatically resolved. We now
+ // have a new keyring which might have keys for the
+ // collections. Recurse.
+ return this.ensureCanSync(extIds);
+ }
+
+ // No conflicts. We're good.
+ return newKeys;
+ }
+
+ /**
+ * Update the kB in the crypto record.
+ */
+ async updateKeyRingKB() {
+ throwIfNoFxA(this._fxaService, 'use of chrome.storage.sync "keyring"');
+ const isSignedIn = !!(await this._fxaService.getSignedInUser());
+ if (!isSignedIn) {
+ // 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
+ // their kB, so just return.
+ return;
+ }
+
+ const thisKBHash = await getKBHash(this._fxaService);
+ await this.cryptoCollection.updateKBHash(thisKBHash);
+ }
+
+ /**
+ * 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.
+ */
+ async checkSyncKeyRing() {
+ await this.updateKeyRingKB();
+
+ const cryptoKeyRecord = await this.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.
+ await this._syncKeyRing(cryptoKeyRecord);
+ }
+ }
+
+ async _syncKeyRing(cryptoKeyRecord) {
+ throwIfNoFxA(this._fxaService, 'syncing chrome.storage.sync "keyring"');
+ 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.
+ //
+ // It's possible that we can't decode the version on the server.
+ // This can happen if a user is locked out of their account, and
+ // does a "reset password" to get in on a new device. In this
+ // case, we are in a bind -- we can't decrypt the record on the
+ // server, so we can't merge keys. If this happens, we try to
+ // figure out if we're the one with the correct (new) kB or if
+ // we just got locked out because we have the old kB. If we're
+ // the one with the correct kB, we wipe the server and reupload
+ // everything, including a new keyring.
+ //
+ // If another device has wiped the server, we need to reupload
+ // everything we have on our end too, so we detect this by
+ // adding a UUID to the keyring. UUIDs are preserved throughout
+ // the lifetime of a keyring, so the only time a keyring UUID
+ // changes is when a new keyring is uploaded, which only happens
+ // after a server wipe. So when we get a "conflict" (resolved by
+ // server_wins), we check whether the server version has a new
+ // UUID. If so, reset our sync status, so that we'll reupload
+ // everything.
+ const result = await this.cryptoCollection.sync(this);
+ if (result.resolved.length) {
+ // Automatically-resolved conflict. It should
+ // be for the keys record.
+ const resolutionIds = result.resolved.map(resolution => resolution.id);
+ if (resolutionIds > 1) {
+ // This should never happen -- there is only ever one record
+ // in this collection.
+ log.error(
+ `Too many resolutions for sync-storage-crypto collection: ${JSON.stringify(
+ resolutionIds
+ )}`
+ );
+ }
+ const keyResolution = result.resolved[0];
+ if (keyResolution.id != STORAGE_SYNC_CRYPTO_KEYRING_RECORD_ID) {
+ // This should never happen -- there should only ever be the
+ // keyring in this collection.
+ log.error(
+ `Strange conflict in sync-storage-crypto collection: ${JSON.stringify(
+ resolutionIds
+ )}`
+ );
+ }
+
+ // Due to a bug in the server-side code (see
+ // https://github.com/Kinto/kinto/issues/1209), lots of users'
+ // keyrings were deleted. We discover this by trying to push a
+ // new keyring (because the user aded a new extension), and we
+ // get a conflict. We have SERVER_WINS, so the client will
+ // accept this deleted keyring and delete it locally. Discover
+ // this and undo it.
+ if (keyResolution.accepted === null) {
+ log.error("Conflict spotted -- the server keyring was deleted");
+ await this.cryptoCollection.upsert(keyResolution.rejected);
+ // It's possible that the keyring on the server that was
+ // deleted had keys for other extensions, which had already
+ // encrypted data. For this to happen, another client would
+ // have had to upload the keyring and then the delete happened
+ // before this client did a sync (and got the new extension
+ // and tried to sync the keyring again). Just to be safe,
+ // let's signal that something went wrong and we should wipe
+ // the bucket.
+ throw new ServerKeyringDeleted();
+ }
+
+ if (keyResolution.accepted.uuid != cryptoKeyRecord.uuid) {
+ log.info(
+ `Detected a new UUID (${keyResolution.accepted.uuid}, was ${cryptoKeyRecord.uuid}). Resetting sync status for everything.`
+ );
+ await this.cryptoCollection.resetSyncStatus();
+
+ // Server version is now correct. Return that result.
+ return result;
+ }
+ }
+ // No conflicts, or conflict was just someone else adding keys.
+ return result;
+ } catch (e) {
+ if (
+ KeyRingEncryptionRemoteTransformer.isOutdatedKB(e) ||
+ e instanceof ServerKeyringDeleted ||
+ // This is another way that ServerKeyringDeleted can
+ // manifest; see bug 1350088 for more details.
+ e.message.includes("Server has been flushed.")
+ ) {
+ // Check if our token is still valid, or if we got locked out
+ // between starting the sync and talking to Kinto.
+ const isSessionValid = await this._fxaService.checkAccountStatus();
+ if (isSessionValid) {
+ log.error(
+ "Couldn't decipher old keyring; deleting the default bucket and resetting sync status"
+ );
+ await this._deleteBucket();
+ await this.cryptoCollection.resetSyncStatus();
+
+ // Reupload our keyring, which is the only new keyring.
+ // We don't want client_wins here because another device
+ // could have uploaded another keyring in the meantime.
+ return this.cryptoCollection.sync(this);
+ }
+ }
+ throw e;
+ }
+ }
+
+ registerInUse(extension, context) {
+ // Register that the extension and context are in use.
+ const contexts = extensionContexts.get(extension);
+ if (!contexts.has(context)) {
+ // New context. Register it and make sure it cleans itself up
+ // when it closes.
+ contexts.add(context);
+ context.callOnClose({
+ close: () => cleanUpForContext(extension, context),
+ });
+ }
+ }
+
+ /**
+ * Get the collection for an extension, and register the extension
+ * as being "in use".
+ *
+ * @param {Extension} extension
+ * The extension for which we are seeking
+ * a collection.
+ * @param {Context} context
+ * The context of the extension, so that we can
+ * stop syncing the collection when the extension ends.
+ * @returns {Promise<Collection>}
+ */
+ getCollection(extension, context) {
+ if (lazy.prefPermitsStorageSync !== true) {
+ return Promise.reject({
+ message: `Please set ${STORAGE_SYNC_ENABLED_PREF} to true in about:config`,
+ });
+ }
+ this.registerInUse(extension, context);
+ return openCollection(extension);
+ }
+
+ async set(extension, items, context) {
+ const coll = await this.getCollection(extension, context);
+ const keys = Object.keys(items);
+ const ids = keys.map(keyToId);
+ const changes = await coll.execute(
+ txn => {
+ let changes = {};
+ for (let [i, key] of keys.entries()) {
+ const id = ids[i];
+ let item = items[key];
+ let { oldRecord } = txn.upsert({
+ id,
+ key,
+ data: item,
+ });
+ changes[key] = {
+ newValue: item,
+ };
+ if (oldRecord) {
+ // Extract the "data" field from the old record, which
+ // represents the value part of the key-value store
+ changes[key].oldValue = oldRecord.data;
+ }
+ }
+ return changes;
+ },
+ { preloadIds: ids }
+ );
+ this.notifyListeners(extension, changes);
+ }
+
+ async remove(extension, keys, context) {
+ const coll = await this.getCollection(extension, context);
+ keys = [].concat(keys);
+ const ids = keys.map(keyToId);
+ let changes = {};
+ await coll.execute(
+ txn => {
+ for (let [i, key] of keys.entries()) {
+ const id = ids[i];
+ const res = txn.deleteAny(id);
+ if (res.deleted) {
+ changes[key] = {
+ oldValue: res.data.data,
+ };
+ }
+ }
+ return changes;
+ },
+ { preloadIds: ids }
+ );
+ if (Object.keys(changes).length) {
+ this.notifyListeners(extension, changes);
+ }
+ }
+
+ /* Wipe local data for all collections without causing the changes to be synced */
+ async clearAll() {
+ const extensions = await this.getExtensions();
+ const extIds = Array.from(extensions, extension => extension.id);
+ log.debug(`Clearing extension data for ${JSON.stringify(extIds)}`);
+ if (extIds.length) {
+ const promises = Array.from(extensions, extension => {
+ return openCollection(extension).then(coll => {
+ return coll.clear();
+ });
+ });
+ await Promise.all(promises);
+ }
+
+ // and clear the crypto collection.
+ const cc = await this.cryptoCollection.getCollection();
+ await cc.clear();
+ }
+
+ async clear(extension, context) {
+ // We can't call Collection#clear here, because that just clears
+ // the local database. We have to explicitly delete everything so
+ // that the deletions can be synced as well.
+ const coll = await this.getCollection(extension, context);
+ const res = await coll.list();
+ const records = res.data;
+ const keys = records.map(record => record.key);
+ await this.remove(extension, keys, context);
+ }
+
+ async get(extension, spec, context) {
+ const coll = await this.getCollection(extension, context);
+ let keys, records;
+ if (spec === null) {
+ records = {};
+ const res = await coll.list();
+ for (let record of res.data) {
+ records[record.key] = record.data;
+ }
+ return records;
+ }
+ if (typeof spec === "string") {
+ keys = [spec];
+ records = {};
+ } else if (Array.isArray(spec)) {
+ keys = spec;
+ records = {};
+ } else {
+ keys = Object.keys(spec);
+ records = Cu.cloneInto(spec, {});
+ }
+
+ for (let key of keys) {
+ const res = await coll.getAny(keyToId(key));
+ if (res.data && res.data._status != "deleted") {
+ records[res.data.key] = res.data.data;
+ }
+ }
+
+ return records;
+ }
+
+ async getBytesInUse(extension, keys, context) {
+ // This is defined by the chrome spec as being the length of the key and
+ // the length of the json repr of the value.
+ let size = 0;
+ let data = await this.get(extension, keys, context);
+ for (const [key, value] of Object.entries(data)) {
+ size += key.length + JSON.stringify(value).length;
+ }
+ return size;
+ }
+
+ addOnChangedListener(extension, listener, context) {
+ let listeners = this.listeners.get(extension) || new Set();
+ listeners.add(listener);
+ this.listeners.set(extension, listeners);
+
+ this.registerInUse(extension, context);
+ }
+
+ removeOnChangedListener(extension, listener) {
+ let listeners = this.listeners.get(extension);
+ listeners.delete(listener);
+ if (listeners.size == 0) {
+ this.listeners.delete(extension);
+ }
+ }
+
+ notifyListeners(extension, changes) {
+ lazy.Observers.notify("ext.storage.sync-changed");
+ let listeners = this.listeners.get(extension) || new Set();
+ if (listeners) {
+ for (let listener of listeners) {
+ lazy.ExtensionCommon.runSafeSyncWithoutClone(listener, changes);
+ }
+ }
+ }
+}
+
+extensionStorageSyncKinto = new ExtensionStorageSyncKinto(_fxaService);
+
+// For test use only.
+export const KintoStorageTestUtils = {
+ CollectionKeyEncryptionRemoteTransformer,
+ CryptoCollection,
+ EncryptionRemoteTransformer,
+ KeyRingEncryptionRemoteTransformer,
+ cleanUpForContext,
+ idToKey,
+ keyToId,
+};