546 lines
14 KiB
JavaScript
546 lines
14 KiB
JavaScript
/* 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;
|
|
}
|
|
}
|
|
}
|