diff options
Diffstat (limited to 'services/fxaccounts/FxAccountsStorage.sys.mjs')
-rw-r--r-- | services/fxaccounts/FxAccountsStorage.sys.mjs | 618 |
1 files changed, 618 insertions, 0 deletions
diff --git a/services/fxaccounts/FxAccountsStorage.sys.mjs b/services/fxaccounts/FxAccountsStorage.sys.mjs new file mode 100644 index 0000000000..24c85dbc2d --- /dev/null +++ b/services/fxaccounts/FxAccountsStorage.sys.mjs @@ -0,0 +1,618 @@ +/* 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/. */ + +import { + DATA_FORMAT_VERSION, + DEFAULT_STORAGE_FILENAME, + FXA_PWDMGR_HOST, + FXA_PWDMGR_PLAINTEXT_FIELDS, + FXA_PWDMGR_REALM, + FXA_PWDMGR_SECURE_FIELDS, + log, +} from "resource://gre/modules/FxAccountsCommon.sys.mjs"; + +// A helper function so code can check what fields are able to be stored by +// the storage manager without having a reference to a manager instance. +export function FxAccountsStorageManagerCanStoreField(fieldName) { + return ( + FXA_PWDMGR_PLAINTEXT_FIELDS.has(fieldName) || + FXA_PWDMGR_SECURE_FIELDS.has(fieldName) + ); +} + +// The storage manager object. +export var FxAccountsStorageManager = function (options = {}) { + this.options = { + filename: options.filename || DEFAULT_STORAGE_FILENAME, + baseDir: options.baseDir || Services.dirsvc.get("ProfD", Ci.nsIFile).path, + }; + this.plainStorage = new JSONStorage(this.options); + // Tests may want to pretend secure storage isn't available. + let useSecure = "useSecure" in options ? options.useSecure : true; + if (useSecure) { + this.secureStorage = new LoginManagerStorage(); + } else { + this.secureStorage = null; + } + this._clearCachedData(); + // See .initialize() below - this protects against it not being called. + this._promiseInitialized = Promise.reject("initialize not called"); + // A promise to avoid storage races - see _queueStorageOperation + this._promiseStorageComplete = Promise.resolve(); +}; + +FxAccountsStorageManager.prototype = { + _initialized: false, + _needToReadSecure: true, + + // An initialization routine that *looks* synchronous to the callers, but + // is actually async as everything else waits for it to complete. + initialize(accountData) { + if (this._initialized) { + throw new Error("already initialized"); + } + this._initialized = true; + // If we just throw away our pre-rejected promise it is reported as an + // unhandled exception when it is GCd - so add an empty .catch handler here + // to prevent this. + this._promiseInitialized.catch(() => {}); + this._promiseInitialized = this._initialize(accountData); + }, + + async _initialize(accountData) { + log.trace("initializing new storage manager"); + try { + if (accountData) { + // If accountData is passed we don't need to read any storage. + this._needToReadSecure = false; + // split it into the 2 parts, write it and we are done. + for (let [name, val] of Object.entries(accountData)) { + if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(name)) { + this.cachedPlain[name] = val; + } else if (FXA_PWDMGR_SECURE_FIELDS.has(name)) { + this.cachedSecure[name] = val; + } else { + // Unknown fields are silently discarded, because there is no way + // for them to be read back later. + log.error( + "Unknown FxA field name in user data, it will be ignored", + name + ); + } + } + // write it out and we are done. + await this._write(); + return; + } + // So we were initialized without account data - that means we need to + // read the state from storage. We try and read plain storage first and + // only attempt to read secure storage if the plain storage had a user. + this._needToReadSecure = await this._readPlainStorage(); + if (this._needToReadSecure && this.secureStorage) { + await this._doReadAndUpdateSecure(); + } + } finally { + log.trace("initializing of new storage manager done"); + } + }, + + finalize() { + // We can't throw this instance away while it is still writing or we may + // end up racing with the newly created one. + log.trace("StorageManager finalizing"); + return this._promiseInitialized + .then(() => { + return this._promiseStorageComplete; + }) + .then(() => { + this._promiseStorageComplete = null; + this._promiseInitialized = null; + this._clearCachedData(); + log.trace("StorageManager finalized"); + }); + }, + + // We want to make sure we don't end up doing multiple storage requests + // concurrently - which has a small window for reads if the master-password + // is locked at initialization time and becomes unlocked later, and always + // has an opportunity for updates. + // We also want to make sure we finished writing when finalizing, so we + // can't accidentally end up with the previous user's write finishing after + // a signOut attempts to clear it. + // So all such operations "queue" themselves via this. + _queueStorageOperation(func) { + // |result| is the promise we return - it has no .catch handler, so callers + // of the storage operation still see failure as a normal rejection. + let result = this._promiseStorageComplete.then(func); + // But the promise we assign to _promiseStorageComplete *does* have a catch + // handler so that rejections in one storage operation does not prevent + // future operations from starting (ie, _promiseStorageComplete must never + // be in a rejected state) + this._promiseStorageComplete = result.catch(err => { + log.error("${func} failed: ${err}", { func, err }); + }); + return result; + }, + + // Get the account data by combining the plain and secure storage. + // If fieldNames is specified, it may be a string or an array of strings, + // and only those fields are returned. If not specified the entire account + // data is returned except for "in memory" fields. Note that not specifying + // field names will soon be deprecated/removed - we want all callers to + // specify the fields they care about. + async getAccountData(fieldNames = null) { + await this._promiseInitialized; + // We know we are initialized - this means our .cachedPlain is accurate + // and doesn't need to be read (it was read if necessary by initialize). + // So if there's no uid, there's no user signed in. + if (!("uid" in this.cachedPlain)) { + return null; + } + let result = {}; + if (fieldNames === null) { + // The "old" deprecated way of fetching a logged in user. + for (let [name, value] of Object.entries(this.cachedPlain)) { + result[name] = value; + } + // But the secure data may not have been read, so try that now. + await this._maybeReadAndUpdateSecure(); + // .cachedSecure now has as much as it possibly can (which is possibly + // nothing if (a) secure storage remains locked and (b) we've never updated + // a field to be stored in secure storage.) + for (let [name, value] of Object.entries(this.cachedSecure)) { + result[name] = value; + } + return result; + } + // The new explicit way of getting attributes. + if (!Array.isArray(fieldNames)) { + fieldNames = [fieldNames]; + } + let checkedSecure = false; + for (let fieldName of fieldNames) { + if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(fieldName)) { + if (this.cachedPlain[fieldName] !== undefined) { + result[fieldName] = this.cachedPlain[fieldName]; + } + } else if (FXA_PWDMGR_SECURE_FIELDS.has(fieldName)) { + // We may not have read secure storage yet. + if (!checkedSecure) { + await this._maybeReadAndUpdateSecure(); + checkedSecure = true; + } + if (this.cachedSecure[fieldName] !== undefined) { + result[fieldName] = this.cachedSecure[fieldName]; + } + } else { + throw new Error("unexpected field '" + fieldName + "'"); + } + } + return result; + }, + + // Update just the specified fields. This DOES NOT allow you to change to + // a different user, nor to set the user as signed-out. + async updateAccountData(newFields) { + await this._promiseInitialized; + if (!("uid" in this.cachedPlain)) { + // If this storage instance shows no logged in user, then you can't + // update fields. + throw new Error("No user is logged in"); + } + if (!newFields || "uid" in newFields) { + throw new Error("Can't change uid"); + } + log.debug("_updateAccountData with items", Object.keys(newFields)); + // work out what bucket. + for (let [name, value] of Object.entries(newFields)) { + if (value == null) { + delete this.cachedPlain[name]; + // no need to do the "delete on null" thing for this.cachedSecure - + // we need to keep it until we have managed to read so we can nuke + // it on write. + this.cachedSecure[name] = null; + } else if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(name)) { + this.cachedPlain[name] = value; + } else if (FXA_PWDMGR_SECURE_FIELDS.has(name)) { + this.cachedSecure[name] = value; + } else { + // Throwing seems reasonable here as some client code has explicitly + // specified the field name, so it's either confused or needs to update + // how this field is to be treated. + throw new Error("unexpected field '" + name + "'"); + } + } + // If we haven't yet read the secure data, do so now, else we may write + // out partial data. + await this._maybeReadAndUpdateSecure(); + // Now save it - but don't wait on the _write promise - it's queued up as + // a storage operation, so .finalize() will wait for completion, but no need + // for us to. + this._write(); + }, + + _clearCachedData() { + this.cachedPlain = {}; + // If we don't have secure storage available we have cachedPlain and + // cachedSecure be the same object. + this.cachedSecure = this.secureStorage == null ? this.cachedPlain : {}; + }, + + /* Reads the plain storage and caches the read values in this.cachedPlain. + Only ever called once and unlike the "secure" storage, is expected to never + fail (ie, plain storage is considered always available, whereas secure + storage may be unavailable if it is locked). + + Returns a promise that resolves with true if valid account data was found, + false otherwise. + + Note: _readPlainStorage is only called during initialize, so isn't + protected via _queueStorageOperation() nor _promiseInitialized. + */ + async _readPlainStorage() { + let got; + try { + got = await this.plainStorage.get(); + } catch (err) { + // File hasn't been created yet. That will be done + // when write is called. + if (!err.name == "NotFoundError") { + log.error("Failed to read plain storage", err); + } + // either way, we return null. + got = null; + } + if ( + !got || + !got.accountData || + !got.accountData.uid || + got.version != DATA_FORMAT_VERSION + ) { + return false; + } + // We need to update our .cachedPlain, but can't just assign to it as + // it may need to be the exact same object as .cachedSecure + // As a sanity check, .cachedPlain must be empty (as we are called by init) + // XXX - this would be a good use-case for a RuntimeAssert or similar, as + // being added in bug 1080457. + if (Object.keys(this.cachedPlain).length) { + throw new Error("should be impossible to have cached data already."); + } + for (let [name, value] of Object.entries(got.accountData)) { + this.cachedPlain[name] = value; + } + return true; + }, + + /* If we haven't managed to read the secure storage, try now, so + we can merge our cached data with the data that's already been set. + */ + _maybeReadAndUpdateSecure() { + if (this.secureStorage == null || !this._needToReadSecure) { + return null; + } + return this._queueStorageOperation(() => { + if (this._needToReadSecure) { + // we might have read it by now! + return this._doReadAndUpdateSecure(); + } + return null; + }); + }, + + /* Unconditionally read the secure storage and merge our cached data (ie, data + which has already been set while the secure storage was locked) with + the read data + */ + async _doReadAndUpdateSecure() { + let { uid, email } = this.cachedPlain; + try { + log.debug( + "reading secure storage with existing", + Object.keys(this.cachedSecure) + ); + // If we already have anything in .cachedSecure it means something has + // updated cachedSecure before we've read it. That means that after we do + // manage to read we must write back the merged data. + let needWrite = !!Object.keys(this.cachedSecure).length; + let readSecure = await this.secureStorage.get(uid, email); + // and update our cached data with it - anything already in .cachedSecure + // wins (including the fact it may be null or undefined, the latter + // which means it will be removed from storage. + if (readSecure && readSecure.version != DATA_FORMAT_VERSION) { + log.warn("got secure data but the data format version doesn't match"); + readSecure = null; + } + if (readSecure && readSecure.accountData) { + log.debug( + "secure read fetched items", + Object.keys(readSecure.accountData) + ); + for (let [name, value] of Object.entries(readSecure.accountData)) { + if (!(name in this.cachedSecure)) { + this.cachedSecure[name] = value; + } + } + if (needWrite) { + log.debug("successfully read secure data; writing updated data back"); + await this._doWriteSecure(); + } + } + this._needToReadSecure = false; + } catch (ex) { + if (ex instanceof this.secureStorage.STORAGE_LOCKED) { + log.debug("setAccountData: secure storage is locked trying to read"); + } else { + log.error("failed to read secure storage", ex); + throw ex; + } + } + }, + + _write() { + // We don't want multiple writes happening concurrently, and we also need to + // know when an "old" storage manager is done (this.finalize() waits for this) + return this._queueStorageOperation(() => this.__write()); + }, + + async __write() { + // Write everything back - later we could track what's actually dirty, + // but for now we write it all. + log.debug("writing plain storage", Object.keys(this.cachedPlain)); + let toWritePlain = { + version: DATA_FORMAT_VERSION, + accountData: this.cachedPlain, + }; + await this.plainStorage.set(toWritePlain); + + // If we have no secure storage manager we are done. + if (this.secureStorage == null) { + return; + } + // and only attempt to write to secure storage if we've managed to read it, + // otherwise we might clobber data that's already there. + if (!this._needToReadSecure) { + await this._doWriteSecure(); + } + }, + + /* Do the actual write of secure data. Caller is expected to check if we actually + need to write and to ensure we are in a queued storage operation. + */ + async _doWriteSecure() { + // We need to remove null items here. + for (let [name, value] of Object.entries(this.cachedSecure)) { + if (value == null) { + delete this.cachedSecure[name]; + } + } + log.debug("writing secure storage", Object.keys(this.cachedSecure)); + let toWriteSecure = { + version: DATA_FORMAT_VERSION, + accountData: this.cachedSecure, + }; + try { + await this.secureStorage.set(this.cachedPlain.uid, toWriteSecure); + } catch (ex) { + if (!(ex instanceof this.secureStorage.STORAGE_LOCKED)) { + throw ex; + } + // This shouldn't be possible as once it is unlocked it can't be + // re-locked, and we can only be here if we've previously managed to + // read. + log.error("setAccountData: secure storage is locked trying to write"); + } + }, + + // Delete the data for an account - ie, called on "sign out". + deleteAccountData() { + return this._queueStorageOperation(() => this._deleteAccountData()); + }, + + async _deleteAccountData() { + log.debug("removing account data"); + await this._promiseInitialized; + await this.plainStorage.set(null); + if (this.secureStorage) { + await this.secureStorage.set(null); + } + this._clearCachedData(); + log.debug("account data reset"); + }, +}; + +/** + * JSONStorage constructor that creates instances that may set/get + * to a specified file, in a directory that will be created if it + * doesn't exist. + * + * @param options { + * filename: of the file to write to + * baseDir: directory where the file resides + * } + * @return instance + */ +function JSONStorage(options) { + this.baseDir = options.baseDir; + this.path = PathUtils.join(options.baseDir, options.filename); +} + +JSONStorage.prototype = { + set(contents) { + log.trace( + "starting write of json user data", + contents ? Object.keys(contents.accountData) : "null" + ); + let start = Date.now(); + return IOUtils.makeDirectory(this.baseDir, { ignoreExisting: true }) + .then(IOUtils.writeJSON.bind(null, this.path, contents)) + .then(result => { + log.trace( + "finished write of json user data - took", + Date.now() - start + ); + return result; + }); + }, + + get() { + log.trace("starting fetch of json user data"); + let start = Date.now(); + return IOUtils.readJSON(this.path).then(result => { + log.trace("finished fetch of json user data - took", Date.now() - start); + return result; + }); + }, +}; + +function StorageLockedError() {} + +/** + * LoginManagerStorage constructor that creates instances that set/get + * data stored securely in the nsILoginManager. + * + * @return instance + */ + +export function LoginManagerStorage() {} + +LoginManagerStorage.prototype = { + STORAGE_LOCKED: StorageLockedError, + // The fields in the credentials JSON object that are stored in plain-text + // in the profile directory. All other fields are stored in the login manager, + // and thus are only available when the master-password is unlocked. + + // a hook point for testing. + get _isLoggedIn() { + return Services.logins.isLoggedIn; + }, + + // Clear any data from the login manager. Returns true if the login manager + // was unlocked (even if no existing logins existed) or false if it was + // locked (meaning we don't even know if it existed or not.) + async _clearLoginMgrData() { + try { + // Services.logins might be third-party and broken... + await Services.logins.initializationPromise; + if (!this._isLoggedIn) { + return false; + } + let logins = await Services.logins.searchLoginsAsync({ + origin: FXA_PWDMGR_HOST, + httpRealm: FXA_PWDMGR_REALM, + }); + for (let login of logins) { + Services.logins.removeLogin(login); + } + return true; + } catch (ex) { + log.error("Failed to clear login data: ${}", ex); + return false; + } + }, + + async set(uid, contents) { + if (!contents) { + // Nuke it from the login manager. + let cleared = await this._clearLoginMgrData(); + if (!cleared) { + // just log a message - we verify that the uid matches when + // we reload it, so having a stale entry doesn't really hurt. + log.info("not removing credentials from login manager - not logged in"); + } + log.trace("storage set finished clearing account data"); + return; + } + + // We are saving actual data. + log.trace("starting write of user data to the login manager"); + try { + // Services.logins might be third-party and broken... + // and the stuff into the login manager. + await Services.logins.initializationPromise; + // If MP is locked we silently fail - the user may need to re-auth + // next startup. + if (!this._isLoggedIn) { + log.info("not saving credentials to login manager - not logged in"); + throw new this.STORAGE_LOCKED(); + } + // write the data to the login manager. + let loginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" + ); + let login = new loginInfo( + FXA_PWDMGR_HOST, + null, // aFormActionOrigin, + FXA_PWDMGR_REALM, // aHttpRealm, + uid, // aUsername + JSON.stringify(contents), // aPassword + "", // aUsernameField + "" + ); // aPasswordField + + let existingLogins = await Services.logins.searchLoginsAsync({ + origin: FXA_PWDMGR_HOST, + httpRealm: FXA_PWDMGR_REALM, + }); + if (existingLogins.length) { + Services.logins.modifyLogin(existingLogins[0], login); + } else { + await Services.logins.addLoginAsync(login); + } + log.trace("finished write of user data to the login manager"); + } catch (ex) { + if (ex instanceof this.STORAGE_LOCKED) { + throw ex; + } + // just log and consume the error here - it may be a 3rd party login + // manager replacement that's simply broken. + log.error("Failed to save data to the login manager", ex); + } + }, + + async get(uid, email) { + log.trace("starting fetch of user data from the login manager"); + + try { + // Services.logins might be third-party and broken... + // read the data from the login manager and merge it for return. + await Services.logins.initializationPromise; + + if (!this._isLoggedIn) { + log.info( + "returning partial account data as the login manager is locked." + ); + throw new this.STORAGE_LOCKED(); + } + + let logins = await Services.logins.searchLoginsAsync({ + origin: FXA_PWDMGR_HOST, + httpRealm: FXA_PWDMGR_REALM, + }); + if (!logins.length) { + // This could happen if the MP was locked when we wrote the data. + log.info("Can't find any credentials in the login manager"); + return null; + } + let login = logins[0]; + // Support either the uid or the email as the username - as of bug 1183951 + // we store the uid, but we support having either for b/w compat. + if (login.username == uid || login.username == email) { + return JSON.parse(login.password); + } + log.info("username in the login manager doesn't match - ignoring it"); + await this._clearLoginMgrData(); + } catch (ex) { + if (ex instanceof this.STORAGE_LOCKED) { + throw ex; + } + // just log and consume the error here - it may be a 3rd party login + // manager replacement that's simply broken. + log.error("Failed to get data from the login manager", ex); + } + return null; + }, +}; |