diff options
Diffstat (limited to 'services/sync/modules/engines/passwords.sys.mjs')
-rw-r--r-- | services/sync/modules/engines/passwords.sys.mjs | 546 |
1 files changed, 546 insertions, 0 deletions
diff --git a/services/sync/modules/engines/passwords.sys.mjs b/services/sync/modules/engines/passwords.sys.mjs new file mode 100644 index 0000000000..8dea5664be --- /dev/null +++ b/services/sync/modules/engines/passwords.sys.mjs @@ -0,0 +1,546 @@ +/* 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 { CryptoWrapper } from "resource://services-sync/record.sys.mjs"; + +import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs"; +import { CollectionValidator } from "resource://services-sync/collection_validator.sys.mjs"; +import { + Changeset, + Store, + SyncEngine, + Tracker, +} from "resource://services-sync/engines.sys.mjs"; +import { Svc, Utils } from "resource://services-sync/util.sys.mjs"; + +// These are valid fields the server could have for a logins record +// we mainly use this to detect if there are any unknownFields and +// store (but don't process) those fields to roundtrip them back +const VALID_LOGIN_FIELDS = [ + "id", + "displayOrigin", + "formSubmitURL", + "formActionOrigin", + "httpRealm", + "hostname", + "origin", + "password", + "passwordField", + "timeCreated", + "timeLastUsed", + "timePasswordChanged", + "timesUsed", + "username", + "usernameField", + "everSynced", + "syncCounter", + "unknownFields", +]; + +import { LoginManagerStorage } from "resource://passwordmgr/passwordstorage.sys.mjs"; + +// Sync and many tests rely on having an time that is rounded to the nearest +// 100th of a second otherwise tests can fail intermittently. +function roundTimeForSync(time) { + return Math.round(time / 10) / 100; +} + +export function LoginRec(collection, id) { + CryptoWrapper.call(this, collection, id); +} + +LoginRec.prototype = { + _logName: "Sync.Record.Login", + + cleartextToString() { + let o = Object.assign({}, this.cleartext); + if (o.password) { + o.password = "X".repeat(o.password.length); + } + return JSON.stringify(o); + }, +}; +Object.setPrototypeOf(LoginRec.prototype, CryptoWrapper.prototype); + +Utils.deferGetSet(LoginRec, "cleartext", [ + "hostname", + "formSubmitURL", + "httpRealm", + "username", + "password", + "usernameField", + "passwordField", + "timeCreated", + "timePasswordChanged", +]); + +export function PasswordEngine(service) { + SyncEngine.call(this, "Passwords", service); +} + +PasswordEngine.prototype = { + _storeObj: PasswordStore, + _trackerObj: PasswordTracker, + _recordObj: LoginRec, + + syncPriority: 2, + + emptyChangeset() { + return new PasswordsChangeset(); + }, + + async ensureCurrentSyncID(newSyncID) { + return Services.logins.ensureCurrentSyncID(newSyncID); + }, + + async getLastSync() { + let legacyValue = await super.getLastSync(); + if (legacyValue) { + await this.setLastSync(legacyValue); + Svc.PrefBranch.clearUserPref(this.name + ".lastSync"); + this._log.debug( + `migrated timestamp of ${legacyValue} to the logins store` + ); + return legacyValue; + } + return this._store.storage.getLastSync(); + }, + + async setLastSync(timestamp) { + await this._store.storage.setLastSync(timestamp); + }, + + // Testing function to emulate that a login has been synced. + async markSynced(guid) { + this._store.storage.resetSyncCounter(guid, 0); + }, + + async pullAllChanges() { + return this._getChangedIDs(true); + }, + + async getChangedIDs() { + return this._getChangedIDs(false); + }, + + async _getChangedIDs(getAll) { + let changes = {}; + + let logins = await this._store.storage.getAllLogins(true); + for (let login of logins) { + if (getAll || login.syncCounter > 0) { + if (Utils.getSyncCredentialsHosts().has(login.origin)) { + continue; + } + + changes[login.guid] = { + counter: login.syncCounter, // record the initial counter value + modified: roundTimeForSync(login.timePasswordChanged), + deleted: this._store.storage.loginIsDeleted(login.guid), + }; + } + } + + return changes; + }, + + async trackRemainingChanges() { + // Reset the syncCounter on the items that were changed. + for (let [guid, { counter, synced }] of Object.entries( + this._modified.changes + )) { + if (synced) { + this._store.storage.resetSyncCounter(guid, counter); + } + } + }, + + async _findDupe(item) { + let login = this._store._nsLoginInfoFromRecord(item); + if (!login) { + return null; + } + + let logins = await this._store.storage.searchLoginsAsync({ + origin: login.origin, + formActionOrigin: login.formActionOrigin, + httpRealm: login.httpRealm, + }); + + // Look for existing logins that match the origin, but ignore the password. + for (let local of logins) { + if (login.matches(local, true) && local instanceof Ci.nsILoginMetaInfo) { + return local.guid; + } + } + + return null; + }, + + _deleteId(id) { + this._noteDeletedId(id); + }, + + getValidator() { + return new PasswordValidator(); + }, +}; +Object.setPrototypeOf(PasswordEngine.prototype, SyncEngine.prototype); + +function PasswordStore(name, engine) { + Store.call(this, name, engine); + this._nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" + ); + this.storage = LoginManagerStorage.create(); +} +PasswordStore.prototype = { + _newPropertyBag() { + return Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + }, + + // Returns an stringified object of any fields not "known" by this client + // mainly used to to prevent data loss for other clients by roundtripping + // these fields without processing them + _processUnknownFields(record) { + let unknownFields = {}; + let keys = Object.keys(record); + keys + .filter(key => !VALID_LOGIN_FIELDS.includes(key)) + .forEach(key => { + unknownFields[key] = record[key]; + }); + // If we found some unknown fields, we stringify it to be able + // to properly encrypt it for roundtripping since we can't know if + // it contained sensitive fields or not + if (Object.keys(unknownFields).length) { + return JSON.stringify(unknownFields); + } + return null; + }, + + /** + * Return an instance of nsILoginInfo (and, implicitly, nsILoginMetaInfo). + */ + _nsLoginInfoFromRecord(record) { + function nullUndefined(x) { + return x == undefined ? null : x; + } + + function stringifyNullUndefined(x) { + return x == undefined || x == null ? "" : x; + } + + if (record.formSubmitURL && record.httpRealm) { + this._log.warn( + "Record " + + record.id + + " has both formSubmitURL and httpRealm. Skipping." + ); + return null; + } + + // Passing in "undefined" results in an empty string, which later + // counts as a value. Explicitly `|| null` these fields according to JS + // truthiness. Records with empty strings or null will be unmolested. + let info = new this._nsLoginInfo( + record.hostname, + nullUndefined(record.formSubmitURL), + nullUndefined(record.httpRealm), + stringifyNullUndefined(record.username), + record.password, + record.usernameField, + record.passwordField + ); + + info.QueryInterface(Ci.nsILoginMetaInfo); + info.guid = record.id; + if (record.timeCreated && !isNaN(new Date(record.timeCreated).getTime())) { + info.timeCreated = record.timeCreated; + } + if ( + record.timePasswordChanged && + !isNaN(new Date(record.timePasswordChanged).getTime()) + ) { + info.timePasswordChanged = record.timePasswordChanged; + } + + // Check the record if there are any unknown fields from other clients + // that we want to roundtrip during sync to prevent data loss + let unknownFields = this._processUnknownFields(record.cleartext); + if (unknownFields) { + info.unknownFields = unknownFields; + } + return info; + }, + + async _getLoginFromGUID(guid) { + let logins = await this.storage.searchLoginsAsync({ guid }, true); + if (logins.length) { + this._log.trace(logins.length + " items matching " + guid + " found."); + return logins[0]; + } + + this._log.trace("No items matching " + guid + " found. Ignoring"); + return null; + }, + + async applyIncoming(record) { + if (record.deleted) { + // Need to supply the sourceSync flag. + await this.remove(record, { sourceSync: true }); + return; + } + + await super.applyIncoming(record); + }, + + async getAllIDs() { + let items = {}; + let logins = await this.storage.getAllLogins(true); + + for (let i = 0; i < logins.length; i++) { + // Skip over Weave password/passphrase entries. + let metaInfo = logins[i].QueryInterface(Ci.nsILoginMetaInfo); + if (Utils.getSyncCredentialsHosts().has(metaInfo.origin)) { + continue; + } + + items[metaInfo.guid] = metaInfo; + } + + return items; + }, + + async changeItemID(oldID, newID) { + this._log.trace("Changing item ID: " + oldID + " to " + newID); + + if (!(await this.itemExists(oldID))) { + this._log.trace("Can't change item ID: item doesn't exist"); + return; + } + if (await this._getLoginFromGUID(newID)) { + this._log.trace("Can't change item ID: new ID already in use"); + return; + } + + let prop = this._newPropertyBag(); + prop.setPropertyAsAUTF8String("guid", newID); + + let oldLogin = await this._getLoginFromGUID(oldID); + this.storage.modifyLogin(oldLogin, prop, true); + }, + + async itemExists(id) { + let login = await this._getLoginFromGUID(id); + return login && !this.storage.loginIsDeleted(id); + }, + + async createRecord(id, collection) { + let record = new LoginRec(collection, id); + let login = await this._getLoginFromGUID(id); + + if (!login || this.storage.loginIsDeleted(id)) { + record.deleted = true; + return record; + } + + record.hostname = login.origin; + record.formSubmitURL = login.formActionOrigin; + record.httpRealm = login.httpRealm; + record.username = login.username; + record.password = login.password; + record.usernameField = login.usernameField; + record.passwordField = login.passwordField; + + // Optional fields. + login.QueryInterface(Ci.nsILoginMetaInfo); + record.timeCreated = login.timeCreated; + record.timePasswordChanged = login.timePasswordChanged; + + // put the unknown fields back to the top-level record + // during upload + if (login.unknownFields) { + let unknownFields = JSON.parse(login.unknownFields); + if (unknownFields) { + Object.keys(unknownFields).forEach(key => { + // We have to manually add it to the cleartext since that's + // what gets processed during upload + record.cleartext[key] = unknownFields[key]; + }); + } + } + + return record; + }, + + async create(record) { + let login = this._nsLoginInfoFromRecord(record); + if (!login) { + return; + } + + login.everSynced = true; + + this._log.trace("Adding login for " + record.hostname); + this._log.trace( + "httpRealm: " + + JSON.stringify(login.httpRealm) + + "; " + + "formSubmitURL: " + + JSON.stringify(login.formActionOrigin) + ); + await Services.logins.addLoginAsync(login); + }, + + async remove(record, { sourceSync = false } = {}) { + this._log.trace("Removing login " + record.id); + + let loginItem = await this._getLoginFromGUID(record.id); + if (!loginItem) { + this._log.trace("Asked to remove record that doesn't exist, ignoring"); + return; + } + + this.storage.removeLogin(loginItem, sourceSync); + }, + + async update(record) { + let loginItem = await this._getLoginFromGUID(record.id); + if (!loginItem || this.storage.loginIsDeleted(record.id)) { + this._log.trace("Skipping update for unknown item: " + record.hostname); + return; + } + + this._log.trace("Updating " + record.hostname); + let newinfo = this._nsLoginInfoFromRecord(record); + if (!newinfo) { + return; + } + + loginItem.everSynced = true; + + this.storage.modifyLogin(loginItem, newinfo, true); + }, + + async wipe() { + this.storage.removeAllUserFacingLogins(true); + }, +}; +Object.setPrototypeOf(PasswordStore.prototype, Store.prototype); + +function PasswordTracker(name, engine) { + Tracker.call(this, name, engine); +} +PasswordTracker.prototype = { + onStart() { + Svc.Obs.add("passwordmgr-storage-changed", this.asyncObserver); + }, + + onStop() { + Svc.Obs.remove("passwordmgr-storage-changed", this.asyncObserver); + }, + + async observe(subject, topic, data) { + if (this.ignoreAll) { + return; + } + + switch (data) { + case "modifyLogin": + // The syncCounter should have been incremented only for + // those items that need to be sycned. + if ( + subject.QueryInterface(Ci.nsIArrayExtensions).GetElementAt(1) + .syncCounter > 0 + ) { + this.score += SCORE_INCREMENT_XLARGE; + } + break; + + case "addLogin": + case "removeLogin": + case "importLogins": + this.score += SCORE_INCREMENT_XLARGE; + break; + + case "removeAllLogins": + this.score += + SCORE_INCREMENT_XLARGE * + (subject.QueryInterface(Ci.nsIArrayExtensions).Count() + 1); + break; + } + }, +}; +Object.setPrototypeOf(PasswordTracker.prototype, Tracker.prototype); + +export class PasswordValidator extends CollectionValidator { + constructor() { + super("passwords", "id", [ + "hostname", + "formSubmitURL", + "httpRealm", + "password", + "passwordField", + "username", + "usernameField", + ]); + } + + async getClientItems() { + let logins = await Services.logins.getAllLogins(); + let syncHosts = Utils.getSyncCredentialsHosts(); + let result = logins + .map(l => l.QueryInterface(Ci.nsILoginMetaInfo)) + .filter(l => !syncHosts.has(l.origin)); + return Promise.resolve(result); + } + + normalizeClientItem(item) { + return { + id: item.guid, + guid: item.guid, + hostname: item.hostname, + formSubmitURL: item.formSubmitURL, + httpRealm: item.httpRealm, + password: item.password, + passwordField: item.passwordField, + username: item.username, + usernameField: item.usernameField, + original: item, + }; + } + + async normalizeServerItem(item) { + return Object.assign({ guid: item.id }, item); + } +} + +export class PasswordsChangeset extends Changeset { + getModifiedTimestamp(id) { + return this.changes[id].modified; + } + + has(id) { + let change = this.changes[id]; + if (change) { + return !change.synced; + } + return false; + } + + delete(id) { + let change = this.changes[id]; + if (change) { + // Mark the change as synced without removing it from the set. + // This allows the sync counter to be reset when sync is complete + // within trackRemainingChanges. + change.synced = true; + } + } +} |