tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

FxAccountsStorage.sys.mjs (22545B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import {
      6  DATA_FORMAT_VERSION,
      7  DEFAULT_STORAGE_FILENAME,
      8  FXA_PWDMGR_HOST,
      9  FXA_PWDMGR_PLAINTEXT_FIELDS,
     10  FXA_PWDMGR_REALM,
     11  FXA_PWDMGR_SECURE_FIELDS,
     12  log,
     13 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
     14 
     15 // A helper function so code can check what fields are able to be stored by
     16 // the storage manager without having a reference to a manager instance.
     17 export function FxAccountsStorageManagerCanStoreField(fieldName) {
     18  return (
     19    FXA_PWDMGR_PLAINTEXT_FIELDS.has(fieldName) ||
     20    FXA_PWDMGR_SECURE_FIELDS.has(fieldName)
     21  );
     22 }
     23 
     24 // The storage manager object.
     25 export var FxAccountsStorageManager = function (options = {}) {
     26  this.options = {
     27    filename: options.filename || DEFAULT_STORAGE_FILENAME,
     28    baseDir: options.baseDir || Services.dirsvc.get("ProfD", Ci.nsIFile).path,
     29  };
     30  this.plainStorage = new JSONStorage(this.options);
     31  // Tests may want to pretend secure storage isn't available.
     32  let useSecure = "useSecure" in options ? options.useSecure : true;
     33  if (useSecure) {
     34    this.secureStorage = new LoginManagerStorage();
     35  } else {
     36    this.secureStorage = null;
     37  }
     38  this._clearCachedData();
     39  // See .initialize() below - this protects against it not being called.
     40  this._promiseInitialized = Promise.reject("initialize not called");
     41  // A promise to avoid storage races - see _queueStorageOperation
     42  this._promiseStorageComplete = Promise.resolve();
     43 };
     44 
     45 FxAccountsStorageManager.prototype = {
     46  _initialized: false,
     47  _needToReadSecure: true,
     48 
     49  // An initialization routine that *looks* synchronous to the callers, but
     50  // is actually async as everything else waits for it to complete.
     51  initialize(accountData) {
     52    if (this._initialized) {
     53      throw new Error("already initialized");
     54    }
     55    this._initialized = true;
     56    // If we just throw away our pre-rejected promise it is reported as an
     57    // unhandled exception when it is GCd - so add an empty .catch handler here
     58    // to prevent this.
     59    this._promiseInitialized.catch(() => {});
     60    this._promiseInitialized = this._initialize(accountData);
     61  },
     62 
     63  async _initialize(accountData) {
     64    log.trace("initializing new storage manager");
     65    try {
     66      if (accountData) {
     67        // If accountData is passed we don't need to read any storage.
     68        this._needToReadSecure = false;
     69        // split it into the 2 parts, write it and we are done.
     70        for (let [name, val] of Object.entries(accountData)) {
     71          if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(name)) {
     72            this.cachedPlain[name] = val;
     73          } else if (FXA_PWDMGR_SECURE_FIELDS.has(name)) {
     74            this.cachedSecure[name] = val;
     75          } else {
     76            // Unknown fields are silently discarded, because there is no way
     77            // for them to be read back later.
     78            log.error(
     79              "Unknown FxA field name in user data, it will be ignored",
     80              name
     81            );
     82          }
     83        }
     84        // write it out and we are done.
     85        await this._write();
     86        return;
     87      }
     88      // So we were initialized without account data - that means we need to
     89      // read the state from storage. We try and read plain storage first and
     90      // only attempt to read secure storage if the plain storage had a user.
     91      this._needToReadSecure = await this._readPlainStorage();
     92      if (this._needToReadSecure && this.secureStorage) {
     93        await this._doReadAndUpdateSecure();
     94      }
     95    } finally {
     96      log.trace("initializing of new storage manager done");
     97    }
     98  },
     99 
    100  finalize() {
    101    // We can't throw this instance away while it is still writing or we may
    102    // end up racing with the newly created one.
    103    log.trace("StorageManager finalizing");
    104    return this._promiseInitialized
    105      .then(() => {
    106        return this._promiseStorageComplete;
    107      })
    108      .then(() => {
    109        this._promiseStorageComplete = null;
    110        this._promiseInitialized = null;
    111        this._clearCachedData();
    112        log.trace("StorageManager finalized");
    113      });
    114  },
    115 
    116  // We want to make sure we don't end up doing multiple storage requests
    117  // concurrently - which has a small window for reads if the master-password
    118  // is locked at initialization time and becomes unlocked later, and always
    119  // has an opportunity for updates.
    120  // We also want to make sure we finished writing when finalizing, so we
    121  // can't accidentally end up with the previous user's write finishing after
    122  // a signOut attempts to clear it.
    123  // So all such operations "queue" themselves via this.
    124  _queueStorageOperation(func) {
    125    // |result| is the promise we return - it has no .catch handler, so callers
    126    // of the storage operation still see failure as a normal rejection.
    127    let result = this._promiseStorageComplete.then(func);
    128    // But the promise we assign to _promiseStorageComplete *does* have a catch
    129    // handler so that rejections in one storage operation does not prevent
    130    // future operations from starting (ie, _promiseStorageComplete must never
    131    // be in a rejected state)
    132    this._promiseStorageComplete = result.catch(err => {
    133      log.error("${func} failed: ${err}", { func, err });
    134    });
    135    return result;
    136  },
    137 
    138  // Get the account data by combining the plain and secure storage.
    139  // If fieldNames is specified, it may be a string or an array of strings,
    140  // and only those fields are returned. If not specified the entire account
    141  // data is returned except for "in memory" fields. Note that not specifying
    142  // field names will soon be deprecated/removed - we want all callers to
    143  // specify the fields they care about.
    144  async getAccountData(fieldNames = null) {
    145    await this._promiseInitialized;
    146    // We know we are initialized - this means our .cachedPlain is accurate
    147    // and doesn't need to be read (it was read if necessary by initialize).
    148    // So if there's no uid, there's no user signed in.
    149    if (!("uid" in this.cachedPlain)) {
    150      return null;
    151    }
    152    let result = {};
    153    if (fieldNames === null) {
    154      // The "old" deprecated way of fetching a logged in user.
    155      for (let [name, value] of Object.entries(this.cachedPlain)) {
    156        result[name] = value;
    157      }
    158      // But the secure data may not have been read, so try that now.
    159      await this._maybeReadAndUpdateSecure();
    160      // .cachedSecure now has as much as it possibly can (which is possibly
    161      // nothing if (a) secure storage remains locked and (b) we've never updated
    162      // a field to be stored in secure storage.)
    163      for (let [name, value] of Object.entries(this.cachedSecure)) {
    164        result[name] = value;
    165      }
    166      return result;
    167    }
    168    // The new explicit way of getting attributes.
    169    if (!Array.isArray(fieldNames)) {
    170      fieldNames = [fieldNames];
    171    }
    172    let checkedSecure = false;
    173    for (let fieldName of fieldNames) {
    174      if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(fieldName)) {
    175        if (this.cachedPlain[fieldName] !== undefined) {
    176          result[fieldName] = this.cachedPlain[fieldName];
    177        }
    178      } else if (FXA_PWDMGR_SECURE_FIELDS.has(fieldName)) {
    179        // We may not have read secure storage yet.
    180        if (!checkedSecure) {
    181          await this._maybeReadAndUpdateSecure();
    182          checkedSecure = true;
    183        }
    184        if (this.cachedSecure[fieldName] !== undefined) {
    185          result[fieldName] = this.cachedSecure[fieldName];
    186        }
    187      } else {
    188        throw new Error("unexpected field '" + fieldName + "'");
    189      }
    190    }
    191    return result;
    192  },
    193 
    194  // Update just the specified fields. This DOES NOT allow you to change to
    195  // a different user, nor to set the user as signed-out.
    196  async updateAccountData(newFields) {
    197    await this._promiseInitialized;
    198    if (!("uid" in this.cachedPlain)) {
    199      // If this storage instance shows no logged in user, then you can't
    200      // update fields.
    201      throw new Error("No user is logged in");
    202    }
    203    if (!newFields || "uid" in newFields) {
    204      throw new Error("Can't change uid");
    205    }
    206    log.debug("_updateAccountData with items", Object.keys(newFields));
    207    // work out what bucket.
    208    for (let [name, value] of Object.entries(newFields)) {
    209      if (value == null) {
    210        delete this.cachedPlain[name];
    211        // no need to do the "delete on null" thing for this.cachedSecure -
    212        // we need to keep it until we have managed to read so we can nuke
    213        // it on write.
    214        this.cachedSecure[name] = null;
    215      } else if (FXA_PWDMGR_PLAINTEXT_FIELDS.has(name)) {
    216        this.cachedPlain[name] = value;
    217      } else if (FXA_PWDMGR_SECURE_FIELDS.has(name)) {
    218        this.cachedSecure[name] = value;
    219      } else {
    220        // Throwing seems reasonable here as some client code has explicitly
    221        // specified the field name, so it's either confused or needs to update
    222        // how this field is to be treated.
    223        throw new Error("unexpected field '" + name + "'");
    224      }
    225    }
    226    // If we haven't yet read the secure data, do so now, else we may write
    227    // out partial data.
    228    await this._maybeReadAndUpdateSecure();
    229    // Now save it - but don't wait on the _write promise - it's queued up as
    230    // a storage operation, so .finalize() will wait for completion, but no need
    231    // for us to.
    232    this._write();
    233  },
    234 
    235  _clearCachedData() {
    236    this.cachedPlain = {};
    237    // If we don't have secure storage available we have cachedPlain and
    238    // cachedSecure be the same object.
    239    this.cachedSecure = this.secureStorage == null ? this.cachedPlain : {};
    240  },
    241 
    242  /* Reads the plain storage and caches the read values in this.cachedPlain.
    243     Only ever called once and unlike the "secure" storage, is expected to never
    244     fail (ie, plain storage is considered always available, whereas secure
    245     storage may be unavailable if it is locked).
    246 
    247     Returns a promise that resolves with true if valid account data was found,
    248     false otherwise.
    249 
    250     Note: _readPlainStorage is only called during initialize, so isn't
    251     protected via _queueStorageOperation() nor _promiseInitialized.
    252  */
    253  async _readPlainStorage() {
    254    let got;
    255    try {
    256      got = await this.plainStorage.get();
    257    } catch (err) {
    258      // File hasn't been created yet.  That will be done
    259      // when write is called.
    260      if (!err.name == "NotFoundError") {
    261        log.error("Failed to read plain storage", err);
    262      }
    263      // either way, we return null.
    264      got = null;
    265    }
    266    if (
    267      !got ||
    268      !got.accountData ||
    269      !got.accountData.uid ||
    270      got.version != DATA_FORMAT_VERSION
    271    ) {
    272      return false;
    273    }
    274    // We need to update our .cachedPlain, but can't just assign to it as
    275    // it may need to be the exact same object as .cachedSecure
    276    // As a sanity check, .cachedPlain must be empty (as we are called by init)
    277    // XXX - this would be a good use-case for a RuntimeAssert or similar, as
    278    // being added in bug 1080457.
    279    if (Object.keys(this.cachedPlain).length) {
    280      throw new Error("should be impossible to have cached data already.");
    281    }
    282    for (let [name, value] of Object.entries(got.accountData)) {
    283      this.cachedPlain[name] = value;
    284    }
    285    return true;
    286  },
    287 
    288  /* If we haven't managed to read the secure storage, try now, so
    289     we can merge our cached data with the data that's already been set.
    290  */
    291  _maybeReadAndUpdateSecure() {
    292    if (this.secureStorage == null || !this._needToReadSecure) {
    293      return null;
    294    }
    295    return this._queueStorageOperation(() => {
    296      if (this._needToReadSecure) {
    297        // we might have read it by now!
    298        return this._doReadAndUpdateSecure();
    299      }
    300      return null;
    301    });
    302  },
    303 
    304  /* Unconditionally read the secure storage and merge our cached data (ie, data
    305     which has already been set while the secure storage was locked) with
    306     the read data
    307  */
    308  async _doReadAndUpdateSecure() {
    309    let { uid, email } = this.cachedPlain;
    310    try {
    311      log.debug(
    312        "reading secure storage with existing",
    313        Object.keys(this.cachedSecure)
    314      );
    315      // If we already have anything in .cachedSecure it means something has
    316      // updated cachedSecure before we've read it. That means that after we do
    317      // manage to read we must write back the merged data.
    318      let needWrite = !!Object.keys(this.cachedSecure).length;
    319      let readSecure = await this.secureStorage.get(uid, email);
    320      // and update our cached data with it - anything already in .cachedSecure
    321      // wins (including the fact it may be null or undefined, the latter
    322      // which means it will be removed from storage.
    323      if (readSecure && readSecure.version != DATA_FORMAT_VERSION) {
    324        log.warn("got secure data but the data format version doesn't match");
    325        readSecure = null;
    326      }
    327      if (readSecure && readSecure.accountData) {
    328        log.debug(
    329          "secure read fetched items",
    330          Object.keys(readSecure.accountData)
    331        );
    332        for (let [name, value] of Object.entries(readSecure.accountData)) {
    333          if (!(name in this.cachedSecure)) {
    334            this.cachedSecure[name] = value;
    335          }
    336        }
    337        if (needWrite) {
    338          log.debug("successfully read secure data; writing updated data back");
    339          await this._doWriteSecure();
    340        }
    341      }
    342      this._needToReadSecure = false;
    343    } catch (ex) {
    344      if (ex instanceof this.secureStorage.STORAGE_LOCKED) {
    345        log.debug("setAccountData: secure storage is locked trying to read");
    346      } else {
    347        log.error("failed to read secure storage", ex);
    348        throw ex;
    349      }
    350    }
    351  },
    352 
    353  _write() {
    354    // We don't want multiple writes happening concurrently, and we also need to
    355    // know when an "old" storage manager is done (this.finalize() waits for this)
    356    return this._queueStorageOperation(() => this.__write());
    357  },
    358 
    359  async __write() {
    360    // Write everything back - later we could track what's actually dirty,
    361    // but for now we write it all.
    362    log.debug("writing plain storage", Object.keys(this.cachedPlain));
    363    let toWritePlain = {
    364      version: DATA_FORMAT_VERSION,
    365      accountData: this.cachedPlain,
    366    };
    367    await this.plainStorage.set(toWritePlain);
    368 
    369    // If we have no secure storage manager we are done.
    370    if (this.secureStorage == null) {
    371      return;
    372    }
    373    // and only attempt to write to secure storage if we've managed to read it,
    374    // otherwise we might clobber data that's already there.
    375    if (!this._needToReadSecure) {
    376      await this._doWriteSecure();
    377    }
    378  },
    379 
    380  /* Do the actual write of secure data. Caller is expected to check if we actually
    381     need to write and to ensure we are in a queued storage operation.
    382  */
    383  async _doWriteSecure() {
    384    // We need to remove null items here.
    385    for (let [name, value] of Object.entries(this.cachedSecure)) {
    386      if (value == null) {
    387        delete this.cachedSecure[name];
    388      }
    389    }
    390    log.debug("writing secure storage", Object.keys(this.cachedSecure));
    391    let toWriteSecure = {
    392      version: DATA_FORMAT_VERSION,
    393      accountData: this.cachedSecure,
    394    };
    395    try {
    396      await this.secureStorage.set(this.cachedPlain.uid, toWriteSecure);
    397    } catch (ex) {
    398      if (!(ex instanceof this.secureStorage.STORAGE_LOCKED)) {
    399        throw ex;
    400      }
    401      // This shouldn't be possible as once it is unlocked it can't be
    402      // re-locked, and we can only be here if we've previously managed to
    403      // read.
    404      log.error("setAccountData: secure storage is locked trying to write");
    405    }
    406  },
    407 
    408  // Delete the data for an account - ie, called on "sign out".
    409  deleteAccountData() {
    410    return this._queueStorageOperation(() => this._deleteAccountData());
    411  },
    412 
    413  async _deleteAccountData() {
    414    log.debug("removing account data");
    415    await this._promiseInitialized;
    416    await this.plainStorage.set(null);
    417    if (this.secureStorage) {
    418      await this.secureStorage.set(null);
    419    }
    420    this._clearCachedData();
    421    log.debug("account data reset");
    422  },
    423 };
    424 
    425 /**
    426 * JSONStorage constructor that creates instances that may set/get
    427 * to a specified file, in a directory that will be created if it
    428 * doesn't exist.
    429 *
    430 * @param options {
    431 *                  filename: of the file to write to
    432 *                  baseDir: directory where the file resides
    433 *                }
    434 * @return instance
    435 */
    436 function JSONStorage(options) {
    437  this.baseDir = options.baseDir;
    438  this.path = PathUtils.join(options.baseDir, options.filename);
    439 }
    440 
    441 JSONStorage.prototype = {
    442  set(contents) {
    443    log.trace(
    444      "starting write of json user data",
    445      contents ? Object.keys(contents.accountData) : "null"
    446    );
    447    let start = Date.now();
    448    return IOUtils.makeDirectory(this.baseDir, { ignoreExisting: true })
    449      .then(IOUtils.writeJSON.bind(null, this.path, contents))
    450      .then(result => {
    451        log.trace(
    452          "finished write of json user data - took",
    453          Date.now() - start
    454        );
    455        return result;
    456      });
    457  },
    458 
    459  get() {
    460    log.trace("starting fetch of json user data");
    461    let start = Date.now();
    462    return IOUtils.readJSON(this.path).then(result => {
    463      log.trace("finished fetch of json user data - took", Date.now() - start);
    464      return result;
    465    });
    466  },
    467 };
    468 
    469 function StorageLockedError() {}
    470 
    471 /**
    472 * LoginManagerStorage constructor that creates instances that set/get
    473 * data stored securely in the nsILoginManager.
    474 *
    475 * @return instance
    476 */
    477 
    478 export function LoginManagerStorage() {}
    479 
    480 LoginManagerStorage.prototype = {
    481  STORAGE_LOCKED: StorageLockedError,
    482  // The fields in the credentials JSON object that are stored in plain-text
    483  // in the profile directory.  All other fields are stored in the login manager,
    484  // and thus are only available when the master-password is unlocked.
    485 
    486  // a hook point for testing.
    487  get _isLoggedIn() {
    488    return Services.logins.isLoggedIn;
    489  },
    490 
    491  // Clear any data from the login manager.  Returns true if the login manager
    492  // was unlocked (even if no existing logins existed) or false if it was
    493  // locked (meaning we don't even know if it existed or not.)
    494  async _clearLoginMgrData() {
    495    try {
    496      // Services.logins might be third-party and broken...
    497      await Services.logins.initializationPromise;
    498      if (!this._isLoggedIn) {
    499        return false;
    500      }
    501      let logins = await Services.logins.searchLoginsAsync({
    502        origin: FXA_PWDMGR_HOST,
    503        httpRealm: FXA_PWDMGR_REALM,
    504      });
    505      for (let login of logins) {
    506        Services.logins.removeLogin(login);
    507      }
    508      return true;
    509    } catch (ex) {
    510      log.error("Failed to clear login data: ${}", ex);
    511      return false;
    512    }
    513  },
    514 
    515  async set(uid, contents) {
    516    if (!contents) {
    517      // Nuke it from the login manager.
    518      let cleared = await this._clearLoginMgrData();
    519      if (!cleared) {
    520        // just log a message - we verify that the uid matches when
    521        // we reload it, so having a stale entry doesn't really hurt.
    522        log.info("not removing credentials from login manager - not logged in");
    523      }
    524      log.trace("storage set finished clearing account data");
    525      return;
    526    }
    527 
    528    // We are saving actual data.
    529    log.trace("starting write of user data to the login manager");
    530    try {
    531      // Services.logins might be third-party and broken...
    532      // and the stuff into the login manager.
    533      await Services.logins.initializationPromise;
    534      // If MP is locked we silently fail - the user may need to re-auth
    535      // next startup.
    536      if (!this._isLoggedIn) {
    537        log.info("not saving credentials to login manager - not logged in");
    538        throw new this.STORAGE_LOCKED();
    539      }
    540      // write the data to the login manager.
    541      let loginInfo = new Components.Constructor(
    542        "@mozilla.org/login-manager/loginInfo;1",
    543        Ci.nsILoginInfo,
    544        "init"
    545      );
    546      let login = new loginInfo(
    547        FXA_PWDMGR_HOST,
    548        null, // aFormActionOrigin,
    549        FXA_PWDMGR_REALM, // aHttpRealm,
    550        uid, // aUsername
    551        JSON.stringify(contents), // aPassword
    552        "", // aUsernameField
    553        ""
    554      ); // aPasswordField
    555 
    556      let existingLogins = await Services.logins.searchLoginsAsync({
    557        origin: FXA_PWDMGR_HOST,
    558        httpRealm: FXA_PWDMGR_REALM,
    559      });
    560      if (existingLogins.length) {
    561        await Services.logins.modifyLoginAsync(existingLogins[0], login);
    562      } else {
    563        await Services.logins.addLoginAsync(login);
    564      }
    565      log.trace("finished write of user data to the login manager");
    566    } catch (ex) {
    567      if (ex instanceof this.STORAGE_LOCKED) {
    568        throw ex;
    569      }
    570      // just log and consume the error here - it may be a 3rd party login
    571      // manager replacement that's simply broken.
    572      log.error("Failed to save data to the login manager", ex);
    573    }
    574  },
    575 
    576  async get(uid, email) {
    577    log.trace("starting fetch of user data from the login manager");
    578 
    579    try {
    580      // Services.logins might be third-party and broken...
    581      // read the data from the login manager and merge it for return.
    582      await Services.logins.initializationPromise;
    583 
    584      if (!this._isLoggedIn) {
    585        log.info(
    586          "returning partial account data as the login manager is locked."
    587        );
    588        throw new this.STORAGE_LOCKED();
    589      }
    590 
    591      let logins = await Services.logins.searchLoginsAsync({
    592        origin: FXA_PWDMGR_HOST,
    593        httpRealm: FXA_PWDMGR_REALM,
    594      });
    595      if (!logins.length) {
    596        // This could happen if the MP was locked when we wrote the data.
    597        log.info("Can't find any credentials in the login manager");
    598        return null;
    599      }
    600      let login = logins[0];
    601      // Support either the uid or the email as the username - as of bug 1183951
    602      // we store the uid, but we support having either for b/w compat.
    603      if (login.username == uid || login.username == email) {
    604        return JSON.parse(login.password);
    605      }
    606      log.info("username in the login manager doesn't match - ignoring it");
    607      await this._clearLoginMgrData();
    608    } catch (ex) {
    609      if (ex instanceof this.STORAGE_LOCKED) {
    610        throw ex;
    611      }
    612      // just log and consume the error here - it may be a 3rd party login
    613      // manager replacement that's simply broken.
    614      log.error("Failed to get data from the login manager", ex);
    615    }
    616    return null;
    617  },
    618 };