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 }