Source code

Revision control

Copy as Markdown

Other Tools

/* 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,
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;
}
}
}