util.sys.mjs (21639B)
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 { Observers } from "resource://services-common/observers.sys.mjs"; 6 7 import { CommonUtils } from "resource://services-common/utils.sys.mjs"; 8 import { CryptoUtils } from "moz-src:///services/crypto/modules/utils.sys.mjs"; 9 10 import { 11 DEVICE_TYPE_DESKTOP, 12 MAXIMUM_BACKOFF_INTERVAL, 13 PREFS_BRANCH, 14 SYNC_KEY_DECODED_LENGTH, 15 SYNC_KEY_ENCODED_LENGTH, 16 WEAVE_VERSION, 17 } from "resource://services-sync/constants.sys.mjs"; 18 19 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 20 21 const lazy = {}; 22 import * as FxAccountsCommon from "resource://gre/modules/FxAccountsCommon.sys.mjs"; 23 24 XPCOMUtils.defineLazyServiceGetter( 25 lazy, 26 "cryptoSDR", 27 "@mozilla.org/login-manager/crypto/SDR;1", 28 Ci.nsILoginManagerCrypto 29 ); 30 31 XPCOMUtils.defineLazyPreferenceGetter( 32 lazy, 33 "localDeviceType", 34 "services.sync.client.type", 35 DEVICE_TYPE_DESKTOP 36 ); 37 38 /* 39 * Custom exception types. 40 */ 41 class LockException extends Error { 42 constructor(message) { 43 super(message); 44 this.name = "LockException"; 45 } 46 } 47 48 class HMACMismatch extends Error { 49 constructor(message) { 50 super(message); 51 this.name = "HMACMismatch"; 52 } 53 } 54 55 /* 56 * Utility functions 57 */ 58 export var Utils = { 59 // Aliases from CryptoUtils. 60 generateRandomBytesLegacy: CryptoUtils.generateRandomBytesLegacy, 61 computeHTTPMACSHA1: CryptoUtils.computeHTTPMACSHA1, 62 digestUTF8: CryptoUtils.digestUTF8, 63 digestBytes: CryptoUtils.digestBytes, 64 sha256: CryptoUtils.sha256, 65 hkdfExpand: CryptoUtils.hkdfExpand, 66 pbkdf2Generate: CryptoUtils.pbkdf2Generate, 67 getHTTPMACSHA1Header: CryptoUtils.getHTTPMACSHA1Header, 68 69 /** 70 * The string to use as the base User-Agent in Sync requests. 71 * This string will look something like 72 * 73 * Firefox/49.0a1 (Windows NT 6.1; WOW64; rv:46.0) FxSync/1.51.0.20160516142357.desktop 74 */ 75 _userAgent: null, 76 get userAgent() { 77 if (!this._userAgent) { 78 let hph = Cc["@mozilla.org/network/protocol;1?name=http"].getService( 79 Ci.nsIHttpProtocolHandler 80 ); 81 /* eslint-disable no-multi-spaces */ 82 this._userAgent = 83 Services.appinfo.name + 84 "/" + 85 Services.appinfo.version + // Product. 86 " (" + 87 hph.oscpu + 88 ")" + // (oscpu) 89 " FxSync/" + 90 WEAVE_VERSION + 91 "." + // Sync. 92 Services.appinfo.appBuildID + 93 "."; // Build. 94 /* eslint-enable no-multi-spaces */ 95 } 96 return this._userAgent + lazy.localDeviceType; 97 }, 98 99 /** 100 * Wrap a [promise-returning] function to catch all exceptions and log them. 101 * 102 * Optionally pass a function which will be called if an 103 * exception occurs. 104 * 105 * @example 106 * MyObj._catch = Utils.catch; 107 * MyObj.foo = function() { this._catch(func)(); } 108 */ 109 catch(func, exceptionCallback) { 110 let thisArg = this; 111 return async function WrappedCatch() { 112 try { 113 return await func.call(thisArg); 114 } catch (ex) { 115 thisArg._log.debug( 116 "Exception calling " + (func.name || "anonymous function"), 117 ex 118 ); 119 if (exceptionCallback) { 120 return exceptionCallback.call(thisArg, ex); 121 } 122 return null; 123 } 124 }; 125 }, 126 127 throwLockException(label) { 128 throw new LockException(`Could not acquire lock. Label: "${label}".`); 129 }, 130 131 /** 132 * Wrap a [promise-returning] function to call lock before calling the function 133 * then unlock when it finishes executing or if it threw an error. 134 * 135 * @example 136 * MyObj._lock = Utils.lock; 137 * MyObj.foo = async function() { await this._lock(func)(); } 138 */ 139 lock(label, func) { 140 let thisArg = this; 141 return async function WrappedLock() { 142 if (!thisArg.lock()) { 143 Utils.throwLockException(label); 144 } 145 146 try { 147 return await func.call(thisArg); 148 } finally { 149 thisArg.unlock(); 150 } 151 }; 152 }, 153 154 isLockException: function isLockException(ex) { 155 return ex instanceof LockException; 156 }, 157 158 /** 159 * Wrap [promise-returning] functions to notify when it starts and 160 * finishes executing or if it threw an error. 161 * 162 * The message is a combination of a provided prefix, the local name, and 163 * the event. Possible events are: "start", "finish", "error". The subject 164 * is the function's return value on "finish" or the caught exception on 165 * "error". The data argument is the predefined data value. 166 * 167 * @example 168 * function MyObj(name) { 169 * this.name = name; 170 * this._notify = Utils.notify("obj:"); 171 * } 172 * MyObj.prototype = { 173 * foo: function() this._notify("func", "data-arg", async function () { 174 * //... 175 * }(), 176 * }; 177 */ 178 notify(prefix) { 179 return function NotifyMaker(name, data, func) { 180 let thisArg = this; 181 let notify = function (state, subject) { 182 let mesg = prefix + name + ":" + state; 183 thisArg._log.trace("Event: " + mesg); 184 Observers.notify(mesg, subject, data); 185 }; 186 187 return async function WrappedNotify() { 188 notify("start", null); 189 try { 190 let ret = await func.call(thisArg); 191 notify("finish", ret); 192 return ret; 193 } catch (ex) { 194 notify("error", ex); 195 throw ex; 196 } 197 }; 198 }; 199 }, 200 201 /** 202 * GUIDs are 9 random bytes encoded with base64url (RFC 4648). 203 * That makes them 12 characters long with 72 bits of entropy. 204 */ 205 makeGUID: function makeGUID() { 206 return CommonUtils.encodeBase64URL(Utils.generateRandomBytesLegacy(9)); 207 }, 208 209 _base64url_regex: /^[-abcdefghijklmnopqrstuvwxyz0123456789_]{12}$/i, 210 checkGUID: function checkGUID(guid) { 211 return !!guid && this._base64url_regex.test(guid); 212 }, 213 214 /** 215 * Add a simple getter/setter to an object that defers access of a property 216 * to an inner property. 217 * 218 * @param obj 219 * Object to add properties to defer in its prototype 220 * @param defer 221 * Property of obj to defer to 222 * @param prop 223 * Property name to defer (or an array of property names) 224 */ 225 deferGetSet: function Utils_deferGetSet(obj, defer, prop) { 226 if (Array.isArray(prop)) { 227 return prop.map(prop => Utils.deferGetSet(obj, defer, prop)); 228 } 229 230 let prot = obj.prototype; 231 232 // Create a getter if it doesn't exist yet 233 if (!prot.__lookupGetter__(prop)) { 234 prot.__defineGetter__(prop, function () { 235 return this[defer][prop]; 236 }); 237 } 238 239 // Create a setter if it doesn't exist yet 240 if (!prot.__lookupSetter__(prop)) { 241 prot.__defineSetter__(prop, function (val) { 242 this[defer][prop] = val; 243 }); 244 } 245 }, 246 247 deepEquals: function eq(a, b) { 248 // If they're triple equals, then it must be equals! 249 if (a === b) { 250 return true; 251 } 252 253 // If they weren't equal, they must be objects to be different 254 if (typeof a != "object" || typeof b != "object") { 255 return false; 256 } 257 258 // But null objects won't have properties to compare 259 if (a === null || b === null) { 260 return false; 261 } 262 263 // Make sure all of a's keys have a matching value in b 264 for (let k in a) { 265 if (!eq(a[k], b[k])) { 266 return false; 267 } 268 } 269 270 // Do the same for b's keys but skip those that we already checked 271 for (let k in b) { 272 if (!(k in a) && !eq(a[k], b[k])) { 273 return false; 274 } 275 } 276 277 return true; 278 }, 279 280 // Generator and discriminator for HMAC exceptions. 281 // Split these out in case we want to make them richer in future, and to 282 // avoid inevitable confusion if the message changes. 283 throwHMACMismatch: function throwHMACMismatch(shouldBe, is) { 284 throw new HMACMismatch( 285 `Record SHA256 HMAC mismatch: should be ${shouldBe}, is ${is}` 286 ); 287 }, 288 289 isHMACMismatch: function isHMACMismatch(ex) { 290 return ex instanceof HMACMismatch; 291 }, 292 293 /** 294 * Turn RFC 4648 base32 into our own user-friendly version. 295 * ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 296 * becomes 297 * abcdefghijk8mn9pqrstuvwxyz234567 298 */ 299 base32ToFriendly: function base32ToFriendly(input) { 300 return input.toLowerCase().replace(/l/g, "8").replace(/o/g, "9"); 301 }, 302 303 base32FromFriendly: function base32FromFriendly(input) { 304 return input.toUpperCase().replace(/8/g, "L").replace(/9/g, "O"); 305 }, 306 307 /** 308 * Key manipulation. 309 */ 310 311 // Return an octet string in friendly base32 *with no trailing =*. 312 encodeKeyBase32: function encodeKeyBase32(keyData) { 313 return Utils.base32ToFriendly(CommonUtils.encodeBase32(keyData)).slice( 314 0, 315 SYNC_KEY_ENCODED_LENGTH 316 ); 317 }, 318 319 decodeKeyBase32: function decodeKeyBase32(encoded) { 320 return CommonUtils.decodeBase32( 321 Utils.base32FromFriendly(Utils.normalizePassphrase(encoded)) 322 ).slice(0, SYNC_KEY_DECODED_LENGTH); 323 }, 324 325 jsonFilePath(...args) { 326 let [fileName] = args.splice(-1); 327 328 return PathUtils.join( 329 PathUtils.profileDir, 330 "weave", 331 ...args, 332 `${fileName}.json` 333 ); 334 }, 335 336 /** 337 * Load a JSON file from disk in the profile directory. 338 * 339 * @param filePath 340 * JSON file path load from profile. Loaded file will be 341 * extension. 342 * @param that 343 * Object to use for logging. 344 * 345 * @return Promise<> 346 * Promise resolved when the write has been performed. 347 */ 348 async jsonLoad(filePath, that) { 349 let path; 350 if (Array.isArray(filePath)) { 351 path = Utils.jsonFilePath(...filePath); 352 } else { 353 path = Utils.jsonFilePath(filePath); 354 } 355 356 if (that._log && that._log.trace) { 357 that._log.trace("Loading json from disk: " + path); 358 } 359 360 try { 361 return await IOUtils.readJSON(path); 362 } catch (e) { 363 if (!DOMException.isInstance(e) || e.name !== "NotFoundError") { 364 if (that._log) { 365 that._log.debug("Failed to load json", e); 366 } 367 } 368 return null; 369 } 370 }, 371 372 /** 373 * Save a json-able object to disk in the profile directory. 374 * 375 * @param filePath 376 * JSON file path save to <filePath>.json 377 * @param that 378 * Object to use for logging. 379 * @param obj 380 * Function to provide json-able object to save. If this isn't a 381 * function, it'll be used as the object to make a json string.* 382 * Function called when the write has been performed. Optional. 383 * 384 * @return Promise<> 385 * Promise resolved when the write has been performed. 386 */ 387 async jsonSave(filePath, that, obj) { 388 let path = PathUtils.join( 389 PathUtils.profileDir, 390 "weave", 391 ...(filePath + ".json").split("/") 392 ); 393 let dir = PathUtils.parent(path); 394 395 await IOUtils.makeDirectory(dir, { createAncestors: true }); 396 397 if (that._log) { 398 that._log.trace("Saving json to disk: " + path); 399 } 400 401 let json = typeof obj == "function" ? obj.call(that) : obj; 402 403 return IOUtils.writeJSON(path, json); 404 }, 405 406 /** 407 * Helper utility function to fit an array of records so that when serialized, 408 * they will be within payloadSizeMaxBytes. Returns a new array without the 409 * items. 410 * 411 * Note: This shouldn't be used for extremely large record sizes as 412 * it uses JSON.stringify, which could lead to a heavy performance hit. 413 * See Bug 1815151 for more details. 414 * 415 */ 416 tryFitItems(records, payloadSizeMaxBytes) { 417 // Copy this so that callers don't have to do it in advance. 418 records = records.slice(); 419 let encoder = Utils.utf8Encoder; 420 const computeSerializedSize = () => 421 encoder.encode(JSON.stringify(records)).byteLength; 422 // Figure out how many records we can pack into a payload. 423 // We use byteLength here because the data is not encrypted in ascii yet. 424 let size = computeSerializedSize(); 425 // See bug 535326 comment 8 for an explanation of the estimation 426 const maxSerializedSize = (payloadSizeMaxBytes / 4) * 3 - 1500; 427 if (maxSerializedSize < 0) { 428 // This is probably due to a test, but it causes very bad behavior if a 429 // test causes this accidentally. We could throw, but there's an obvious/ 430 // natural way to handle it, so we do that instead (otherwise we'd have a 431 // weird lower bound of ~1125b on the max record payload size). 432 return []; 433 } 434 if (size > maxSerializedSize) { 435 // Estimate a little more than the direct fraction to maximize packing 436 let cutoff = Math.ceil((records.length * maxSerializedSize) / size); 437 records = records.slice(0, cutoff + 1); 438 439 // Keep dropping off the last entry until the data fits. 440 while (computeSerializedSize() > maxSerializedSize) { 441 records.pop(); 442 } 443 } 444 return records; 445 }, 446 447 /** 448 * Move a json file in the profile directory. Will fail if a file exists at the 449 * destination. 450 * 451 * @returns a promise that resolves to undefined on success, or rejects on failure 452 * 453 * @param aFrom 454 * Current path to the JSON file saved on disk, relative to profileDir/weave 455 * .json will be appended to the file name. 456 * @param aTo 457 * New path to the JSON file saved on disk, relative to profileDir/weave 458 * .json will be appended to the file name. 459 * @param that 460 * Object to use for logging 461 */ 462 jsonMove(aFrom, aTo, that) { 463 let pathFrom = PathUtils.join( 464 PathUtils.profileDir, 465 "weave", 466 ...(aFrom + ".json").split("/") 467 ); 468 let pathTo = PathUtils.join( 469 PathUtils.profileDir, 470 "weave", 471 ...(aTo + ".json").split("/") 472 ); 473 if (that._log) { 474 that._log.trace("Moving " + pathFrom + " to " + pathTo); 475 } 476 return IOUtils.move(pathFrom, pathTo, { noOverwrite: true }); 477 }, 478 479 /** 480 * Removes a json file in the profile directory. 481 * 482 * @returns a promise that resolves to undefined on success, or rejects on failure 483 * 484 * @param filePath 485 * Current path to the JSON file saved on disk, relative to profileDir/weave 486 * .json will be appended to the file name. 487 * @param that 488 * Object to use for logging 489 */ 490 jsonRemove(filePath, that) { 491 let path = PathUtils.join( 492 PathUtils.profileDir, 493 "weave", 494 ...(filePath + ".json").split("/") 495 ); 496 if (that._log) { 497 that._log.trace("Deleting " + path); 498 } 499 return IOUtils.remove(path, { ignoreAbsent: true }); 500 }, 501 502 /** 503 * The following are the methods supported for UI use: 504 * 505 * * isPassphrase: 506 * determines whether a string is either a normalized or presentable 507 * passphrase. 508 * * normalizePassphrase: 509 * take a presentable passphrase and reduce it to a normalized 510 * representation for storage. normalizePassphrase can safely be called 511 * on normalized input. 512 */ 513 514 isPassphrase(s) { 515 if (s) { 516 return /^[abcdefghijkmnpqrstuvwxyz23456789]{26}$/.test( 517 Utils.normalizePassphrase(s) 518 ); 519 } 520 return false; 521 }, 522 523 normalizePassphrase: function normalizePassphrase(pp) { 524 // Short var name... have you seen the lines below?! 525 // Allow leading and trailing whitespace. 526 pp = pp.trim().toLowerCase(); 527 528 // 20-char sync key. 529 if (pp.length == 23 && [5, 11, 17].every(i => pp[i] == "-")) { 530 return ( 531 pp.slice(0, 5) + pp.slice(6, 11) + pp.slice(12, 17) + pp.slice(18, 23) 532 ); 533 } 534 535 // "Modern" 26-char key. 536 if (pp.length == 31 && [1, 7, 13, 19, 25].every(i => pp[i] == "-")) { 537 return ( 538 pp.slice(0, 1) + 539 pp.slice(2, 7) + 540 pp.slice(8, 13) + 541 pp.slice(14, 19) + 542 pp.slice(20, 25) + 543 pp.slice(26, 31) 544 ); 545 } 546 547 // Something else -- just return. 548 return pp; 549 }, 550 551 /** 552 * Create an array like the first but without elements of the second. Reuse 553 * arrays if possible. 554 */ 555 arraySub: function arraySub(minuend, subtrahend) { 556 if (!minuend.length || !subtrahend.length) { 557 return minuend; 558 } 559 let setSubtrahend = new Set(subtrahend); 560 return minuend.filter(i => !setSubtrahend.has(i)); 561 }, 562 563 /** 564 * Build the union of two arrays. Reuse arrays if possible. 565 */ 566 arrayUnion: function arrayUnion(foo, bar) { 567 if (!foo.length) { 568 return bar; 569 } 570 if (!bar.length) { 571 return foo; 572 } 573 return foo.concat(Utils.arraySub(bar, foo)); 574 }, 575 576 /** 577 * Add all the items in `items` to the provided Set in-place. 578 * 579 * @return The provided set. 580 */ 581 setAddAll(set, items) { 582 for (let item of items) { 583 set.add(item); 584 } 585 return set; 586 }, 587 588 /** 589 * Delete every items in `items` to the provided Set in-place. 590 * 591 * @return The provided set. 592 */ 593 setDeleteAll(set, items) { 594 for (let item of items) { 595 set.delete(item); 596 } 597 return set; 598 }, 599 600 /** 601 * Take the first `size` items from the Set `items`. 602 * 603 * @return A Set of size at most `size` 604 */ 605 subsetOfSize(items, size) { 606 let result = new Set(); 607 let count = 0; 608 for (let item of items) { 609 if (count++ == size) { 610 return result; 611 } 612 result.add(item); 613 } 614 return result; 615 }, 616 617 bind2: function Async_bind2(object, method) { 618 return function innerBind() { 619 return method.apply(object, arguments); 620 }; 621 }, 622 623 /** 624 * Is there a master password configured and currently locked? 625 */ 626 mpLocked() { 627 return !lazy.cryptoSDR.isLoggedIn; 628 }, 629 630 // If Master Password is enabled and locked, present a dialog to unlock it. 631 // Return whether the system is unlocked. 632 ensureMPUnlocked() { 633 if (lazy.cryptoSDR.uiBusy) { 634 return false; 635 } 636 try { 637 lazy.cryptoSDR.encrypt("bacon"); 638 return true; 639 } catch (e) {} 640 return false; 641 }, 642 643 /** 644 * Return a value for a backoff interval. Maximum is eight hours, unless 645 * Status.backoffInterval is higher. 646 * 647 */ 648 calculateBackoff: function calculateBackoff( 649 attempts, 650 baseInterval, 651 statusInterval 652 ) { 653 let backoffInterval = 654 attempts * (Math.floor(Math.random() * baseInterval) + baseInterval); 655 return Math.max( 656 Math.min(backoffInterval, MAXIMUM_BACKOFF_INTERVAL), 657 statusInterval 658 ); 659 }, 660 661 /** 662 * Return a set of hostnames (including the protocol) which may have 663 * credentials for sync itself stored in the login manager. 664 * 665 * In general, these hosts will not have their passwords synced, will be 666 * reset when we drop sync credentials, etc. 667 */ 668 getSyncCredentialsHosts() { 669 let result = new Set(); 670 // the FxA host 671 result.add(FxAccountsCommon.FXA_PWDMGR_HOST); 672 // We used to include the FxA hosts (hence the Set() result) but we now 673 // don't give them special treatment (hence the Set() with exactly 1 item) 674 return result; 675 }, 676 677 /** 678 * Helper to implement a more efficient version of fairly common pattern: 679 * 680 * Utils.defineLazyIDProperty(this, "syncID", "services.sync.client.syncID") 681 * 682 * is equivalent to (but more efficient than) the following: 683 * 684 * Foo.prototype = { 685 * ... 686 * get syncID() { 687 * let syncID = Svc.PrefBranch.getStringPref("client.syncID", ""); 688 * return syncID == "" ? this.syncID = Utils.makeGUID() : syncID; 689 * }, 690 * set syncID(value) { 691 * Svc.PrefBranch.setStringPref("client.syncID", value); 692 * }, 693 * ... 694 * }; 695 */ 696 defineLazyIDProperty(object, propName, prefName) { 697 // An object that exists to be the target of the lazy pref getter. 698 // We can't use `object` (at least, not using `propName`) since XPCOMUtils 699 // will stomp on any setter we define. 700 const storage = {}; 701 XPCOMUtils.defineLazyPreferenceGetter(storage, "value", prefName, ""); 702 Object.defineProperty(object, propName, { 703 configurable: true, 704 enumerable: true, 705 get() { 706 let value = storage.value; 707 if (!value) { 708 value = Utils.makeGUID(); 709 Services.prefs.setStringPref(prefName, value); 710 } 711 return value; 712 }, 713 set(value) { 714 Services.prefs.setStringPref(prefName, value); 715 }, 716 }); 717 }, 718 719 getDeviceType() { 720 return lazy.localDeviceType; 721 }, 722 723 formatTimestamp(date) { 724 // Format timestamp as: "%Y-%m-%d %H:%M:%S" 725 let year = String(date.getFullYear()); 726 let month = String(date.getMonth() + 1).padStart(2, "0"); 727 let day = String(date.getDate()).padStart(2, "0"); 728 let hours = String(date.getHours()).padStart(2, "0"); 729 let minutes = String(date.getMinutes()).padStart(2, "0"); 730 let seconds = String(date.getSeconds()).padStart(2, "0"); 731 732 return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; 733 }, 734 735 *walkTree(tree, parent = null) { 736 if (tree) { 737 // Skip root node 738 if (parent) { 739 yield [tree, parent]; 740 } 741 if (tree.children) { 742 for (let child of tree.children) { 743 yield* Utils.walkTree(child, tree); 744 } 745 } 746 } 747 }, 748 }; 749 750 /** 751 * A subclass of Set that serializes as an Array when passed to JSON.stringify. 752 */ 753 export class SerializableSet extends Set { 754 toJSON() { 755 return Array.from(this); 756 } 757 } 758 759 ChromeUtils.defineLazyGetter(Utils, "_utf8Converter", function () { 760 let converter = Cc[ 761 "@mozilla.org/intl/scriptableunicodeconverter" 762 ].createInstance(Ci.nsIScriptableUnicodeConverter); 763 converter.charset = "UTF-8"; 764 return converter; 765 }); 766 767 ChromeUtils.defineLazyGetter(Utils, "utf8Encoder", () => new TextEncoder()); 768 769 /* 770 * Commonly-used services 771 */ 772 export var Svc = {}; 773 774 Svc.PrefBranch = Services.prefs.getBranch(PREFS_BRANCH); 775 Svc.Obs = Observers; 776 777 Svc.Obs.add("xpcom-shutdown", function () { 778 for (let name in Svc) { 779 delete Svc[name]; 780 } 781 });