gecko/services/sync/modules/util.js

724 lines
23 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/. */
this.EXPORTED_SYMBOLS = ["XPCOMUtils", "Services", "Utils", "Async", "Svc", "Str"];
const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://services-common/observers.js");
Cu.import("resource://services-common/stringbundle.js");
Cu.import("resource://services-common/utils.js");
Cu.import("resource://services-common/async.js", this);
Cu.import("resource://services-crypto/utils.js");
Cu.import("resource://services-sync/constants.js");
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/Services.jsm", this);
Cu.import("resource://gre/modules/XPCOMUtils.jsm", this);
Cu.import("resource://gre/modules/osfile.jsm", this);
Cu.import("resource://gre/modules/Task.jsm", this);
// FxAccountsCommon.js doesn't use a "namespace", so create one here.
XPCOMUtils.defineLazyGetter(this, "FxAccountsCommon", function() {
let FxAccountsCommon = {};
Cu.import("resource://gre/modules/FxAccountsCommon.js", FxAccountsCommon);
return FxAccountsCommon;
});
/*
* Utility functions
*/
this.Utils = {
// Alias in functions from CommonUtils. These previously were defined here.
// In the ideal world, references to these would be removed.
nextTick: CommonUtils.nextTick,
namedTimer: CommonUtils.namedTimer,
exceptionStr: CommonUtils.exceptionStr,
stackTrace: CommonUtils.stackTrace,
makeURI: CommonUtils.makeURI,
encodeUTF8: CommonUtils.encodeUTF8,
decodeUTF8: CommonUtils.decodeUTF8,
safeAtoB: CommonUtils.safeAtoB,
byteArrayToString: CommonUtils.byteArrayToString,
bytesAsHex: CommonUtils.bytesAsHex,
hexToBytes: CommonUtils.hexToBytes,
encodeBase32: CommonUtils.encodeBase32,
decodeBase32: CommonUtils.decodeBase32,
// Aliases from CryptoUtils.
generateRandomBytes: CryptoUtils.generateRandomBytes,
computeHTTPMACSHA1: CryptoUtils.computeHTTPMACSHA1,
digestUTF8: CryptoUtils.digestUTF8,
digestBytes: CryptoUtils.digestBytes,
sha1: CryptoUtils.sha1,
sha1Base32: CryptoUtils.sha1Base32,
makeHMACKey: CryptoUtils.makeHMACKey,
makeHMACHasher: CryptoUtils.makeHMACHasher,
hkdfExpand: CryptoUtils.hkdfExpand,
pbkdf2Generate: CryptoUtils.pbkdf2Generate,
deriveKeyFromPassphrase: CryptoUtils.deriveKeyFromPassphrase,
getHTTPMACSHA1Header: CryptoUtils.getHTTPMACSHA1Header,
/**
* Wrap a function to catch all exceptions and log them
*
* @usage MyObj._catch = Utils.catch;
* MyObj.foo = function() { this._catch(func)(); }
*
* Optionally pass a function which will be called if an
* exception occurs.
*/
catch: function Utils_catch(func, exceptionCallback) {
let thisArg = this;
return function WrappedCatch() {
try {
return func.call(thisArg);
}
catch(ex) {
thisArg._log.debug("Exception: " + Utils.exceptionStr(ex));
if (exceptionCallback) {
return exceptionCallback.call(thisArg, ex);
}
return null;
}
};
},
/**
* Wrap a function to call lock before calling the function then unlock.
*
* @usage MyObj._lock = Utils.lock;
* MyObj.foo = function() { this._lock(func)(); }
*/
lock: function lock(label, func) {
let thisArg = this;
return function WrappedLock() {
if (!thisArg.lock()) {
throw "Could not acquire lock. Label: \"" + label + "\".";
}
try {
return func.call(thisArg);
}
finally {
thisArg.unlock();
}
};
},
isLockException: function isLockException(ex) {
return ex && ex.indexOf && ex.indexOf("Could not acquire lock.") == 0;
},
/**
* Wrap functions to notify when it starts and finishes executing or if it
* threw an error.
*
* The message is a combination of a provided prefix, the local name, and
* the event. Possible events are: "start", "finish", "error". The subject
* is the function's return value on "finish" or the caught exception on
* "error". The data argument is the predefined data value.
*
* Example:
*
* @usage function MyObj(name) {
* this.name = name;
* this._notify = Utils.notify("obj:");
* }
* MyObj.prototype = {
* foo: function() this._notify("func", "data-arg", function () {
* //...
* }(),
* };
*/
notify: function Utils_notify(prefix) {
return function NotifyMaker(name, data, func) {
let thisArg = this;
let notify = function(state, subject) {
let mesg = prefix + name + ":" + state;
thisArg._log.trace("Event: " + mesg);
Observers.notify(mesg, subject, data);
};
return function WrappedNotify() {
try {
notify("start", null);
let ret = func.call(thisArg);
notify("finish", ret);
return ret;
}
catch(ex) {
notify("error", ex);
throw ex;
}
};
};
},
/**
* GUIDs are 9 random bytes encoded with base64url (RFC 4648).
* That makes them 12 characters long with 72 bits of entropy.
*/
makeGUID: function makeGUID() {
return CommonUtils.encodeBase64URL(Utils.generateRandomBytes(9));
},
_base64url_regex: /^[-abcdefghijklmnopqrstuvwxyz0123456789_]{12}$/i,
checkGUID: function checkGUID(guid) {
return !!guid && this._base64url_regex.test(guid);
},
/**
* Add a simple getter/setter to an object that defers access of a property
* to an inner property.
*
* @param obj
* Object to add properties to defer in its prototype
* @param defer
* Property of obj to defer to
* @param prop
* Property name to defer (or an array of property names)
*/
deferGetSet: function Utils_deferGetSet(obj, defer, prop) {
if (Array.isArray(prop))
return prop.map(prop => Utils.deferGetSet(obj, defer, prop));
let prot = obj.prototype;
// Create a getter if it doesn't exist yet
if (!prot.__lookupGetter__(prop)) {
prot.__defineGetter__(prop, function () {
return this[defer][prop];
});
}
// Create a setter if it doesn't exist yet
if (!prot.__lookupSetter__(prop)) {
prot.__defineSetter__(prop, function (val) {
this[defer][prop] = val;
});
}
},
lazyStrings: function Weave_lazyStrings(name) {
let bundle = "chrome://weave/locale/services/" + name + ".properties";
return () => new StringBundle(bundle);
},
deepEquals: function eq(a, b) {
// If they're triple equals, then it must be equals!
if (a === b)
return true;
// If they weren't equal, they must be objects to be different
if (typeof a != "object" || typeof b != "object")
return false;
// But null objects won't have properties to compare
if (a === null || b === null)
return false;
// Make sure all of a's keys have a matching value in b
for (let k in a)
if (!eq(a[k], b[k]))
return false;
// Do the same for b's keys but skip those that we already checked
for (let k in b)
if (!(k in a) && !eq(a[k], b[k]))
return false;
return true;
},
// Generator and discriminator for HMAC exceptions.
// Split these out in case we want to make them richer in future, and to
// avoid inevitable confusion if the message changes.
throwHMACMismatch: function throwHMACMismatch(shouldBe, is) {
throw "Record SHA256 HMAC mismatch: should be " + shouldBe + ", is " + is;
},
isHMACMismatch: function isHMACMismatch(ex) {
const hmacFail = "Record SHA256 HMAC mismatch: ";
return ex && ex.indexOf && (ex.indexOf(hmacFail) == 0);
},
/**
* Turn RFC 4648 base32 into our own user-friendly version.
* ABCDEFGHIJKLMNOPQRSTUVWXYZ234567
* becomes
* abcdefghijk8mn9pqrstuvwxyz234567
*/
base32ToFriendly: function base32ToFriendly(input) {
return input.toLowerCase()
.replace(/l/g, '8')
.replace(/o/g, '9');
},
base32FromFriendly: function base32FromFriendly(input) {
return input.toUpperCase()
.replace(/8/g, 'L')
.replace(/9/g, 'O');
},
/**
* Key manipulation.
*/
// Return an octet string in friendly base32 *with no trailing =*.
encodeKeyBase32: function encodeKeyBase32(keyData) {
return Utils.base32ToFriendly(
Utils.encodeBase32(keyData))
.slice(0, SYNC_KEY_ENCODED_LENGTH);
},
decodeKeyBase32: function decodeKeyBase32(encoded) {
return Utils.decodeBase32(
Utils.base32FromFriendly(
Utils.normalizePassphrase(encoded)))
.slice(0, SYNC_KEY_DECODED_LENGTH);
},
base64Key: function base64Key(keyData) {
return btoa(keyData);
},
/**
* N.B., salt should be base64 encoded, even though we have to decode
* it later!
*/
derivePresentableKeyFromPassphrase : function derivePresentableKeyFromPassphrase(passphrase, salt, keyLength, forceJS) {
let k = CryptoUtils.deriveKeyFromPassphrase(passphrase, salt, keyLength,
forceJS);
return Utils.encodeKeyBase32(k);
},
/**
* N.B., salt should be base64 encoded, even though we have to decode
* it later!
*/
deriveEncodedKeyFromPassphrase : function deriveEncodedKeyFromPassphrase(passphrase, salt, keyLength, forceJS) {
let k = CryptoUtils.deriveKeyFromPassphrase(passphrase, salt, keyLength,
forceJS);
return Utils.base64Key(k);
},
/**
* Take a base64-encoded 128-bit AES key, returning it as five groups of five
* uppercase alphanumeric characters, separated by hyphens.
* A.K.A. base64-to-base32 encoding.
*/
presentEncodedKeyAsSyncKey : function presentEncodedKeyAsSyncKey(encodedKey) {
return Utils.encodeKeyBase32(atob(encodedKey));
},
/**
* Load a JSON file from disk in the profile directory.
*
* @param filePath
* JSON file path load from profile. Loaded file will be
* <profile>/<filePath>.json. i.e. Do not specify the ".json"
* extension.
* @param that
* Object to use for logging and "this" for callback.
* @param callback
* Function to process json object as its first argument. If the file
* could not be loaded, the first argument will be undefined.
*/
jsonLoad: Task.async(function*(filePath, that, callback) {
let path = OS.Path.join(OS.Constants.Path.profileDir, "weave", filePath + ".json");
if (that._log) {
that._log.trace("Loading json from disk: " + filePath);
}
let json;
try {
json = yield CommonUtils.readJSON(path);
} catch (e if e instanceof OS.File.Error && e.becauseNoSuchFile) {
// Ignore non-existent files.
} catch (e) {
if (that._log) {
that._log.debug("Failed to load json: " +
CommonUtils.exceptionStr(e));
}
}
callback.call(that, json);
}),
/**
* Save a json-able object to disk in the profile directory.
*
* @param filePath
* JSON file path save to <filePath>.json
* @param that
* Object to use for logging and "this" for callback
* @param obj
* Function to provide json-able object to save. If this isn't a
* function, it'll be used as the object to make a json string.
* @param callback
* Function called when the write has been performed. Optional.
* The first argument will be a Components.results error
* constant on error or null if no error was encountered (and
* the file saved successfully).
*/
jsonSave: Task.async(function*(filePath, that, obj, callback) {
let path = OS.Path.join(OS.Constants.Path.profileDir, "weave",
...(filePath + ".json").split("/"));
let dir = OS.Path.dirname(path);
let error = null;
try {
yield OS.File.makeDir(dir, { from: OS.Constants.Path.profileDir });
if (that._log) {
that._log.trace("Saving json to disk: " + path);
}
let json = typeof obj == "function" ? obj.call(that) : obj;
yield CommonUtils.writeJSON(json, path);
} catch (e) {
error = e
}
if (typeof callback == "function") {
callback.call(that, error);
}
}),
getErrorString: function Utils_getErrorString(error, args) {
try {
return Str.errors.get(error, args || null);
} catch (e) {}
// basically returns "Unknown Error"
return Str.errors.get("error.reason.unknown");
},
/**
* Generate 26 characters.
*/
generatePassphrase: function generatePassphrase() {
// Note that this is a different base32 alphabet to the one we use for
// other tasks. It's lowercase, uses different letters, and needs to be
// decoded with decodeKeyBase32, not just decodeBase32.
return Utils.encodeKeyBase32(CryptoUtils.generateRandomBytes(16));
},
/**
* The following are the methods supported for UI use:
*
* * isPassphrase:
* determines whether a string is either a normalized or presentable
* passphrase.
* * hyphenatePassphrase:
* present a normalized passphrase for display. This might actually
* perform work beyond just hyphenation; sorry.
* * hyphenatePartialPassphrase:
* present a fragment of a normalized passphrase for display.
* * normalizePassphrase:
* take a presentable passphrase and reduce it to a normalized
* representation for storage. normalizePassphrase can safely be called
* on normalized input.
* * normalizeAccount:
* take user input for account/username, cleaning up appropriately.
*/
isPassphrase: function(s) {
if (s) {
return /^[abcdefghijkmnpqrstuvwxyz23456789]{26}$/.test(Utils.normalizePassphrase(s));
}
return false;
},
/**
* Hyphenate a passphrase (26 characters) into groups.
* abbbbccccddddeeeeffffggggh
* =>
* a-bbbbc-cccdd-ddeee-effff-ggggh
*/
hyphenatePassphrase: function hyphenatePassphrase(passphrase) {
// For now, these are the same.
return Utils.hyphenatePartialPassphrase(passphrase, true);
},
hyphenatePartialPassphrase: function hyphenatePartialPassphrase(passphrase, omitTrailingDash) {
if (!passphrase)
return null;
// Get the raw data input. Just base32.
let data = passphrase.toLowerCase().replace(/[^abcdefghijkmnpqrstuvwxyz23456789]/g, "");
// This is the neatest way to do this.
if ((data.length == 1) && !omitTrailingDash)
return data + "-";
// Hyphenate it.
let y = data.substr(0,1);
let z = data.substr(1).replace(/(.{1,5})/g, "-$1");
// Correct length? We're done.
if ((z.length == 30) || omitTrailingDash)
return y + z;
// Add a trailing dash if appropriate.
return (y + z.replace(/([^-]{5})$/, "$1-")).substr(0, SYNC_KEY_HYPHENATED_LENGTH);
},
normalizePassphrase: function normalizePassphrase(pp) {
// Short var name... have you seen the lines below?!
// Allow leading and trailing whitespace.
pp = pp.trim().toLowerCase();
// 20-char sync key.
if (pp.length == 23 &&
[5, 11, 17].every(function(i) pp[i] == '-')) {
return pp.slice(0, 5) + pp.slice(6, 11)
+ pp.slice(12, 17) + pp.slice(18, 23);
}
// "Modern" 26-char key.
if (pp.length == 31 &&
[1, 7, 13, 19, 25].every(function(i) pp[i] == '-')) {
return pp.slice(0, 1) + pp.slice(2, 7)
+ pp.slice(8, 13) + pp.slice(14, 19)
+ pp.slice(20, 25) + pp.slice(26, 31);
}
// Something else -- just return.
return pp;
},
normalizeAccount: function normalizeAccount(acc) {
return acc.trim();
},
/**
* Create an array like the first but without elements of the second. Reuse
* arrays if possible.
*/
arraySub: function arraySub(minuend, subtrahend) {
if (!minuend.length || !subtrahend.length)
return minuend;
return minuend.filter(i => subtrahend.indexOf(i) == -1);
},
/**
* Build the union of two arrays. Reuse arrays if possible.
*/
arrayUnion: function arrayUnion(foo, bar) {
if (!foo.length)
return bar;
if (!bar.length)
return foo;
return foo.concat(Utils.arraySub(bar, foo));
},
bind2: function Async_bind2(object, method) {
return function innerBind() { return method.apply(object, arguments); };
},
/**
* Is there a master password configured, regardless of current lock state?
*/
mpEnabled: function mpEnabled() {
let modules = Cc["@mozilla.org/security/pkcs11moduledb;1"]
.getService(Ci.nsIPKCS11ModuleDB);
let sdrSlot = modules.findSlotByName("");
let status = sdrSlot.status;
let slots = Ci.nsIPKCS11Slot;
return status != slots.SLOT_UNINITIALIZED && status != slots.SLOT_READY;
},
/**
* Is there a master password configured and currently locked?
*/
mpLocked: function mpLocked() {
let modules = Cc["@mozilla.org/security/pkcs11moduledb;1"]
.getService(Ci.nsIPKCS11ModuleDB);
let sdrSlot = modules.findSlotByName("");
let status = sdrSlot.status;
let slots = Ci.nsIPKCS11Slot;
if (status == slots.SLOT_READY || status == slots.SLOT_LOGGED_IN
|| status == slots.SLOT_UNINITIALIZED)
return false;
if (status == slots.SLOT_NOT_LOGGED_IN)
return true;
// something wacky happened, pretend MP is locked
return true;
},
// If Master Password is enabled and locked, present a dialog to unlock it.
// Return whether the system is unlocked.
ensureMPUnlocked: function ensureMPUnlocked() {
if (!Utils.mpLocked()) {
return true;
}
let sdr = Cc["@mozilla.org/security/sdr;1"]
.getService(Ci.nsISecretDecoderRing);
try {
sdr.encryptString("bacon");
return true;
} catch(e) {}
return false;
},
/**
* Return a value for a backoff interval. Maximum is eight hours, unless
* Status.backoffInterval is higher.
*
*/
calculateBackoff: function calculateBackoff(attempts, baseInterval,
statusInterval) {
let backoffInterval = attempts *
(Math.floor(Math.random() * baseInterval) +
baseInterval);
return Math.max(Math.min(backoffInterval, MAXIMUM_BACKOFF_INTERVAL),
statusInterval);
},
/**
* Return a set of hostnames (including the protocol) which may have
* credentials for sync itself stored in the login manager.
*
* In general, these hosts will not have their passwords synced, will be
* reset when we drop sync credentials, etc.
*/
getSyncCredentialsHosts: function() {
let result = new Set(this.getSyncCredentialsHostsLegacy());
for (let host of this.getSyncCredentialsHostsFxA()) {
result.add(host);
}
return result;
},
/*
* Get the "legacy" identity hosts.
*/
getSyncCredentialsHostsLegacy: function() {
// the legacy sync host
return new Set([PWDMGR_HOST]);
},
/*
* Get the FxA identity hosts.
*/
getSyncCredentialsHostsFxA: function() {
// This is somewhat expensive and the result static, so we cache the result.
if (this._syncCredentialsHostsFxA) {
return this._syncCredentialsHostsFxA;
}
let result = new Set();
// the FxA host
result.add(FxAccountsCommon.FXA_PWDMGR_HOST);
//
// The FxA hosts - these almost certainly all have the same hostname, but
// better safe than sorry...
for (let prefName of ["identity.fxaccounts.remote.force_auth.uri",
"identity.fxaccounts.remote.signup.uri",
"identity.fxaccounts.remote.signin.uri",
"identity.fxaccounts.settings.uri"]) {
let prefVal;
try {
prefVal = Services.prefs.getCharPref(prefName);
} catch (_) {
continue;
}
let uri = Services.io.newURI(prefVal, null, null);
result.add(uri.prePath);
}
return this._syncCredentialsHostsFxA = result;
},
getDefaultDeviceName() {
// Generate a client name if we don't have a useful one yet
let env = Cc["@mozilla.org/process/environment;1"]
.getService(Ci.nsIEnvironment);
let user = env.get("USER") || env.get("USERNAME") ||
Svc.Prefs.get("account") || Svc.Prefs.get("username");
// A little hack for people using the the moz-build environment on Windows
// which sets USER to the literal "%USERNAME%" (yes, really)
if (user == "%USERNAME%" && env.get("USERNAME")) {
user = env.get("USERNAME");
}
let brand = new StringBundle("chrome://branding/locale/brand.properties");
let brandName = brand.get("brandShortName");
let appName;
try {
let syncStrings = new StringBundle("chrome://browser/locale/sync.properties");
appName = syncStrings.getFormattedString("sync.defaultAccountApplication", [brandName]);
} catch (ex) {}
appName = appName || brandName;
let system =
// 'device' is defined on unix systems
Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2).get("device") ||
// hostname of the system, usually assigned by the user or admin
Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2).get("host") ||
// fall back on ua info string
Cc["@mozilla.org/network/protocol;1?name=http"].getService(Ci.nsIHttpProtocolHandler).oscpu;
return Str.sync.get("client.name2", [user, appName, system]);
}
};
XPCOMUtils.defineLazyGetter(Utils, "_utf8Converter", function() {
let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
.createInstance(Ci.nsIScriptableUnicodeConverter);
converter.charset = "UTF-8";
return converter;
});
/*
* Commonly-used services
*/
this.Svc = {};
Svc.Prefs = new Preferences(PREFS_BRANCH);
Svc.DefaultPrefs = new Preferences({branch: PREFS_BRANCH, defaultBranch: true});
Svc.Obs = Observers;
let _sessionCID = Services.appinfo.ID == SEAMONKEY_ID ?
"@mozilla.org/suite/sessionstore;1" :
"@mozilla.org/browser/sessionstore;1";
[
["Idle", "@mozilla.org/widget/idleservice;1", "nsIIdleService"],
["Session", _sessionCID, "nsISessionStore"]
].forEach(function([name, contract, iface]) {
XPCOMUtils.defineLazyServiceGetter(Svc, name, contract, iface);
});
XPCOMUtils.defineLazyModuleGetter(Svc, "FormHistory", "resource://gre/modules/FormHistory.jsm");
Svc.__defineGetter__("Crypto", function() {
let cryptoSvc;
let ns = {};
Cu.import("resource://services-crypto/WeaveCrypto.js", ns);
cryptoSvc = new ns.WeaveCrypto();
delete Svc.Crypto;
return Svc.Crypto = cryptoSvc;
});
this.Str = {};
["errors", "sync"].forEach(function(lazy) {
XPCOMUtils.defineLazyGetter(Str, lazy, Utils.lazyStrings(lazy));
});
Svc.Obs.add("xpcom-shutdown", function () {
for (let name in Svc)
delete Svc[name];
});