tor-browser

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

passwords.sys.mjs (14757B)


      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 { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
      6 
      7 import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs";
      8 import { CollectionValidator } from "resource://services-sync/collection_validator.sys.mjs";
      9 import {
     10  Changeset,
     11  Store,
     12  SyncEngine,
     13  Tracker,
     14 } from "resource://services-sync/engines.sys.mjs";
     15 import { Svc, Utils } from "resource://services-sync/util.sys.mjs";
     16 
     17 // These are valid fields the server could have for a logins record
     18 // we mainly use this to detect if there are any unknownFields and
     19 // store (but don't process) those fields to roundtrip them back
     20 const VALID_LOGIN_FIELDS = [
     21  "id",
     22  "displayOrigin",
     23  "formSubmitURL",
     24  "formActionOrigin",
     25  "httpRealm",
     26  "hostname",
     27  "origin",
     28  "password",
     29  "passwordField",
     30  "timeCreated",
     31  "timeLastUsed",
     32  "timePasswordChanged",
     33  "timesUsed",
     34  "username",
     35  "usernameField",
     36  "everSynced",
     37  "syncCounter",
     38  "unknownFields",
     39 ];
     40 
     41 import { LoginManagerStorage } from "resource://passwordmgr/passwordstorage.sys.mjs";
     42 
     43 // Sync and many tests rely on having an time that is rounded to the nearest
     44 // 100th of a second otherwise tests can fail intermittently.
     45 function roundTimeForSync(time) {
     46  return Math.round(time / 10) / 100;
     47 }
     48 
     49 export function LoginRec(collection, id) {
     50  CryptoWrapper.call(this, collection, id);
     51 }
     52 
     53 LoginRec.prototype = {
     54  _logName: "Sync.Record.Login",
     55 
     56  cleartextToString() {
     57    let o = Object.assign({}, this.cleartext);
     58    if (o.password) {
     59      o.password = "X".repeat(o.password.length);
     60    }
     61    return JSON.stringify(o);
     62  },
     63 };
     64 Object.setPrototypeOf(LoginRec.prototype, CryptoWrapper.prototype);
     65 
     66 Utils.deferGetSet(LoginRec, "cleartext", [
     67  "hostname",
     68  "formSubmitURL",
     69  "httpRealm",
     70  "username",
     71  "password",
     72  "usernameField",
     73  "passwordField",
     74  "timeCreated",
     75  "timePasswordChanged",
     76 ]);
     77 
     78 export function PasswordEngine(service) {
     79  SyncEngine.call(this, "Passwords", service);
     80 }
     81 
     82 PasswordEngine.prototype = {
     83  _storeObj: PasswordStore,
     84  _trackerObj: PasswordTracker,
     85  _recordObj: LoginRec,
     86 
     87  syncPriority: 2,
     88 
     89  emptyChangeset() {
     90    return new PasswordsChangeset();
     91  },
     92 
     93  async ensureCurrentSyncID(newSyncID) {
     94    return Services.logins.ensureCurrentSyncID(newSyncID);
     95  },
     96 
     97  async getLastSync() {
     98    let legacyValue = await super.getLastSync();
     99    if (legacyValue) {
    100      await this.setLastSync(legacyValue);
    101      Svc.PrefBranch.clearUserPref(this.name + ".lastSync");
    102      this._log.debug(
    103        `migrated timestamp of ${legacyValue} to the logins store`
    104      );
    105      return legacyValue;
    106    }
    107    return this._store.storage.getLastSync();
    108  },
    109 
    110  async setLastSync(timestamp) {
    111    await this._store.storage.setLastSync(timestamp);
    112  },
    113 
    114  // Testing function to emulate that a login has been synced.
    115  async markSynced(guid) {
    116    this._store.storage.resetSyncCounter(guid, 0);
    117  },
    118 
    119  async pullAllChanges() {
    120    return this._getChangedIDs(true);
    121  },
    122 
    123  async getChangedIDs() {
    124    return this._getChangedIDs(false);
    125  },
    126 
    127  async _getChangedIDs(getAll) {
    128    let changes = {};
    129 
    130    let logins = await this._store.storage.getAllLogins(true);
    131    for (let login of logins) {
    132      if (getAll || login.syncCounter > 0) {
    133        if (Utils.getSyncCredentialsHosts().has(login.origin)) {
    134          continue;
    135        }
    136 
    137        changes[login.guid] = {
    138          counter: login.syncCounter, // record the initial counter value
    139          modified: roundTimeForSync(login.timePasswordChanged),
    140          deleted: this._store.storage.loginIsDeleted(login.guid),
    141        };
    142      }
    143    }
    144 
    145    return changes;
    146  },
    147 
    148  async trackRemainingChanges() {
    149    // Reset the syncCounter on the items that were changed.
    150    for (let [guid, { counter, synced }] of Object.entries(
    151      this._modified.changes
    152    )) {
    153      if (synced) {
    154        this._store.storage.resetSyncCounter(guid, counter);
    155      }
    156    }
    157  },
    158 
    159  async _findDupe(item) {
    160    let login = this._store._nsLoginInfoFromRecord(item);
    161    if (!login) {
    162      return null;
    163    }
    164 
    165    let logins = await this._store.storage.searchLoginsAsync({
    166      origin: login.origin,
    167      formActionOrigin: login.formActionOrigin,
    168      httpRealm: login.httpRealm,
    169    });
    170 
    171    // Look for existing logins that match the origin, but ignore the password.
    172    for (let local of logins) {
    173      if (login.matches(local, true) && local instanceof Ci.nsILoginMetaInfo) {
    174        return local.guid;
    175      }
    176    }
    177 
    178    return null;
    179  },
    180 
    181  _deleteId(id) {
    182    this._noteDeletedId(id);
    183  },
    184 
    185  getValidator() {
    186    return new PasswordValidator();
    187  },
    188 };
    189 Object.setPrototypeOf(PasswordEngine.prototype, SyncEngine.prototype);
    190 
    191 function PasswordStore(name, engine) {
    192  Store.call(this, name, engine);
    193  this._nsLoginInfo = new Components.Constructor(
    194    "@mozilla.org/login-manager/loginInfo;1",
    195    Ci.nsILoginInfo,
    196    "init"
    197  );
    198  this.storage = LoginManagerStorage.create();
    199 }
    200 PasswordStore.prototype = {
    201  _newPropertyBag() {
    202    return Cc["@mozilla.org/hash-property-bag;1"].createInstance(
    203      Ci.nsIWritablePropertyBag2
    204    );
    205  },
    206 
    207  // Returns an stringified object of any fields not "known" by this client
    208  // mainly used to to prevent data loss for other clients by roundtripping
    209  // these fields without processing them
    210  _processUnknownFields(record) {
    211    let unknownFields = {};
    212    let keys = Object.keys(record);
    213    keys
    214      .filter(key => !VALID_LOGIN_FIELDS.includes(key))
    215      .forEach(key => {
    216        unknownFields[key] = record[key];
    217      });
    218    // If we found some unknown fields, we stringify it to be able
    219    // to properly encrypt it for roundtripping since we can't know if
    220    // it contained sensitive fields or not
    221    if (Object.keys(unknownFields).length) {
    222      return JSON.stringify(unknownFields);
    223    }
    224    return null;
    225  },
    226 
    227  /**
    228   * Return an instance of nsILoginInfo (and, implicitly, nsILoginMetaInfo).
    229   */
    230  _nsLoginInfoFromRecord(record) {
    231    function nullUndefined(x) {
    232      return x == undefined ? null : x;
    233    }
    234 
    235    function stringifyNullUndefined(x) {
    236      return x == undefined || x == null ? "" : x;
    237    }
    238 
    239    if (record.formSubmitURL && record.httpRealm) {
    240      this._log.warn(
    241        "Record " +
    242          record.id +
    243          " has both formSubmitURL and httpRealm. Skipping."
    244      );
    245      return null;
    246    }
    247 
    248    // Passing in "undefined" results in an empty string, which later
    249    // counts as a value. Explicitly `|| null` these fields according to JS
    250    // truthiness. Records with empty strings or null will be unmolested.
    251    let info = new this._nsLoginInfo(
    252      record.hostname,
    253      nullUndefined(record.formSubmitURL),
    254      nullUndefined(record.httpRealm),
    255      stringifyNullUndefined(record.username),
    256      record.password,
    257      record.usernameField,
    258      record.passwordField
    259    );
    260 
    261    info.QueryInterface(Ci.nsILoginMetaInfo);
    262    info.guid = record.id;
    263    if (record.timeCreated && !isNaN(new Date(record.timeCreated).getTime())) {
    264      info.timeCreated = record.timeCreated;
    265    }
    266    if (
    267      record.timePasswordChanged &&
    268      !isNaN(new Date(record.timePasswordChanged).getTime())
    269    ) {
    270      info.timePasswordChanged = record.timePasswordChanged;
    271    }
    272 
    273    // Check the record if there are any unknown fields from other clients
    274    // that we want to roundtrip during sync to prevent data loss
    275    let unknownFields = this._processUnknownFields(record.cleartext);
    276    if (unknownFields) {
    277      info.unknownFields = unknownFields;
    278    }
    279    return info;
    280  },
    281 
    282  async _getLoginFromGUID(guid) {
    283    let logins = await this.storage.searchLoginsAsync({ guid }, true);
    284    if (logins.length) {
    285      this._log.trace(logins.length + " items matching " + guid + " found.");
    286      return logins[0];
    287    }
    288 
    289    this._log.trace("No items matching " + guid + " found. Ignoring");
    290    return null;
    291  },
    292 
    293  async applyIncoming(record) {
    294    if (record.deleted) {
    295      // Need to supply the sourceSync flag.
    296      await this.remove(record, { sourceSync: true });
    297      return;
    298    }
    299 
    300    await super.applyIncoming(record);
    301  },
    302 
    303  async getAllIDs() {
    304    let items = {};
    305    let logins = await this.storage.getAllLogins(true);
    306 
    307    for (let i = 0; i < logins.length; i++) {
    308      // Skip over Weave password/passphrase entries.
    309      let metaInfo = logins[i].QueryInterface(Ci.nsILoginMetaInfo);
    310      if (Utils.getSyncCredentialsHosts().has(metaInfo.origin)) {
    311        continue;
    312      }
    313 
    314      items[metaInfo.guid] = metaInfo;
    315    }
    316 
    317    return items;
    318  },
    319 
    320  async changeItemID(oldID, newID) {
    321    this._log.trace("Changing item ID: " + oldID + " to " + newID);
    322 
    323    if (!(await this.itemExists(oldID))) {
    324      this._log.trace("Can't change item ID: item doesn't exist");
    325      return;
    326    }
    327    if (await this._getLoginFromGUID(newID)) {
    328      this._log.trace("Can't change item ID: new ID already in use");
    329      return;
    330    }
    331 
    332    let prop = this._newPropertyBag();
    333    prop.setPropertyAsAUTF8String("guid", newID);
    334 
    335    let oldLogin = await this._getLoginFromGUID(oldID);
    336    await this.storage.modifyLoginAsync(oldLogin, prop, true);
    337  },
    338 
    339  async itemExists(id) {
    340    let login = await this._getLoginFromGUID(id);
    341    return login && !this.storage.loginIsDeleted(id);
    342  },
    343 
    344  async createRecord(id, collection) {
    345    let record = new LoginRec(collection, id);
    346    let login = await this._getLoginFromGUID(id);
    347 
    348    if (!login || this.storage.loginIsDeleted(id)) {
    349      record.deleted = true;
    350      return record;
    351    }
    352 
    353    record.hostname = login.origin;
    354    record.formSubmitURL = login.formActionOrigin;
    355    record.httpRealm = login.httpRealm;
    356    record.username = login.username;
    357    record.password = login.password;
    358    record.usernameField = login.usernameField;
    359    record.passwordField = login.passwordField;
    360 
    361    // Optional fields.
    362    login.QueryInterface(Ci.nsILoginMetaInfo);
    363    record.timeCreated = login.timeCreated;
    364    record.timePasswordChanged = login.timePasswordChanged;
    365 
    366    // put the unknown fields back to the top-level record
    367    // during upload
    368    if (login.unknownFields) {
    369      let unknownFields = JSON.parse(login.unknownFields);
    370      if (unknownFields) {
    371        Object.keys(unknownFields).forEach(key => {
    372          // We have to manually add it to the cleartext since that's
    373          // what gets processed during upload
    374          record.cleartext[key] = unknownFields[key];
    375        });
    376      }
    377    }
    378 
    379    return record;
    380  },
    381 
    382  async create(record) {
    383    let login = this._nsLoginInfoFromRecord(record);
    384    if (!login) {
    385      return;
    386    }
    387 
    388    login.everSynced = true;
    389 
    390    this._log.trace("Adding login for " + record.hostname);
    391    this._log.trace(
    392      "httpRealm: " +
    393        JSON.stringify(login.httpRealm) +
    394        "; " +
    395        "formSubmitURL: " +
    396        JSON.stringify(login.formActionOrigin)
    397    );
    398    await Services.logins.addLoginAsync(login);
    399  },
    400 
    401  async remove(record, { sourceSync = false } = {}) {
    402    this._log.trace("Removing login " + record.id);
    403 
    404    let loginItem = await this._getLoginFromGUID(record.id);
    405    if (!loginItem) {
    406      this._log.trace("Asked to remove record that doesn't exist, ignoring");
    407      return;
    408    }
    409 
    410    this.storage.removeLogin(loginItem, sourceSync);
    411  },
    412 
    413  async update(record) {
    414    let loginItem = await this._getLoginFromGUID(record.id);
    415    if (!loginItem || this.storage.loginIsDeleted(record.id)) {
    416      this._log.trace("Skipping update for unknown item: " + record.hostname);
    417      return;
    418    }
    419 
    420    this._log.trace("Updating " + record.hostname);
    421    let newinfo = this._nsLoginInfoFromRecord(record);
    422    if (!newinfo) {
    423      return;
    424    }
    425 
    426    loginItem.everSynced = true;
    427 
    428    await this.storage.modifyLoginAsync(loginItem, newinfo, true);
    429  },
    430 
    431  async wipe() {
    432    this.storage.removeAllUserFacingLogins(true);
    433  },
    434 };
    435 Object.setPrototypeOf(PasswordStore.prototype, Store.prototype);
    436 
    437 function PasswordTracker(name, engine) {
    438  Tracker.call(this, name, engine);
    439 }
    440 PasswordTracker.prototype = {
    441  onStart() {
    442    Svc.Obs.add("passwordmgr-storage-changed", this.asyncObserver);
    443  },
    444 
    445  onStop() {
    446    Svc.Obs.remove("passwordmgr-storage-changed", this.asyncObserver);
    447  },
    448 
    449  async observe(subject, topic, data) {
    450    if (this.ignoreAll) {
    451      return;
    452    }
    453 
    454    switch (data) {
    455      case "modifyLogin":
    456        // The syncCounter should have been incremented only for
    457        // those items that need to be sycned.
    458        if (
    459          subject.QueryInterface(Ci.nsIArrayExtensions).GetElementAt(1)
    460            .syncCounter > 0
    461        ) {
    462          this.score += SCORE_INCREMENT_XLARGE;
    463        }
    464        break;
    465 
    466      case "addLogin":
    467      case "removeLogin":
    468      case "importLogins":
    469        this.score += SCORE_INCREMENT_XLARGE;
    470        break;
    471 
    472      case "removeAllLogins":
    473        this.score +=
    474          SCORE_INCREMENT_XLARGE *
    475          (subject.QueryInterface(Ci.nsIArrayExtensions).Count() + 1);
    476        break;
    477    }
    478  },
    479 };
    480 Object.setPrototypeOf(PasswordTracker.prototype, Tracker.prototype);
    481 
    482 export class PasswordValidator extends CollectionValidator {
    483  constructor() {
    484    super("passwords", "id", [
    485      "hostname",
    486      "formSubmitURL",
    487      "httpRealm",
    488      "password",
    489      "passwordField",
    490      "username",
    491      "usernameField",
    492    ]);
    493  }
    494 
    495  async getClientItems() {
    496    let logins = await Services.logins.getAllLogins();
    497    let syncHosts = Utils.getSyncCredentialsHosts();
    498    let result = logins
    499      .map(l => l.QueryInterface(Ci.nsILoginMetaInfo))
    500      .filter(l => !syncHosts.has(l.origin));
    501    return Promise.resolve(result);
    502  }
    503 
    504  normalizeClientItem(item) {
    505    return {
    506      id: item.guid,
    507      guid: item.guid,
    508      hostname: item.hostname,
    509      formSubmitURL: item.formSubmitURL,
    510      httpRealm: item.httpRealm,
    511      password: item.password,
    512      passwordField: item.passwordField,
    513      username: item.username,
    514      usernameField: item.usernameField,
    515      original: item,
    516    };
    517  }
    518 
    519  async normalizeServerItem(item) {
    520    return Object.assign({ guid: item.id }, item);
    521  }
    522 }
    523 
    524 export class PasswordsChangeset extends Changeset {
    525  getModifiedTimestamp(id) {
    526    return this.changes[id].modified;
    527  }
    528 
    529  has(id) {
    530    let change = this.changes[id];
    531    if (change) {
    532      return !change.synced;
    533    }
    534    return false;
    535  }
    536 
    537  delete(id) {
    538    let change = this.changes[id];
    539    if (change) {
    540      // Mark the change as synced without removing it from the set.
    541      // This allows the sync counter to be reset when sync is complete
    542      // within trackRemainingChanges.
    543      change.synced = true;
    544    }
    545  }
    546 }