diff -r 000000000000 -r 6474c204b198 services/fxaccounts/FxAccountsClient.jsm --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/services/fxaccounts/FxAccountsClient.jsm Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,391 @@ +/* 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 = ["FxAccountsClient"]; + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://services-common/hawkclient.js"); +Cu.import("resource://services-crypto/utils.js"); +Cu.import("resource://gre/modules/FxAccountsCommon.js"); +Cu.import("resource://gre/modules/Credentials.jsm"); + +const HOST = Services.prefs.getCharPref("identity.fxaccounts.auth.uri"); + +this.FxAccountsClient = function(host = HOST) { + this.host = host; + + // The FxA auth server expects requests to certain endpoints to be authorized + // using Hawk. + this.hawk = new HawkClient(host); + this.hawk.observerPrefix = "FxA:hawk"; + + // Manage server backoff state. C.f. + // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol + this.backoffError = null; +}; + +this.FxAccountsClient.prototype = { + + /** + * Return client clock offset, in milliseconds, as determined by hawk client. + * Provided because callers should not have to know about hawk + * implementation. + * + * The offset is the number of milliseconds that must be added to the client + * clock to make it equal to the server clock. For example, if the client is + * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. + */ + get localtimeOffsetMsec() { + return this.hawk.localtimeOffsetMsec; + }, + + /* + * Return current time in milliseconds + * + * Not used by this module, but made available to the FxAccounts.jsm + * that uses this client. + */ + now: function() { + return this.hawk.now(); + }, + + /** + * Create a new Firefox Account and authenticate + * + * @param email + * The email address for the account (utf8) + * @param password + * The user's password + * @return Promise + * Returns a promise that resolves to an object: + * { + * uid: the user's unique ID (hex) + * sessionToken: a session token (hex) + * keyFetchToken: a key fetch token (hex) + * } + */ + signUp: function(email, password) { + return Credentials.setup(email, password).then((creds) => { + let data = { + email: creds.emailUTF8, + authPW: CommonUtils.bytesAsHex(creds.authPW), + }; + return this._request("/account/create", "POST", null, data); + }); + }, + + /** + * Authenticate and create a new session with the Firefox Account API server + * + * @param email + * The email address for the account (utf8) + * @param password + * The user's password + * @param [getKeys=false] + * If set to true the keyFetchToken will be retrieved + * @param [retryOK=true] + * If capitalization of the email is wrong and retryOK is set to true, + * we will retry with the suggested capitalization from the server + * @return Promise + * Returns a promise that resolves to an object: + * { + * authAt: authentication time for the session (seconds since epoch) + * email: the primary email for this account + * keyFetchToken: a key fetch token (hex) + * sessionToken: a session token (hex) + * uid: the user's unique ID (hex) + * unwrapBKey: used to unwrap kB, derived locally from the + * password (not revealed to the FxA server) + * verified: flag indicating verification status of the email + * } + */ + signIn: function signIn(email, password, getKeys=false, retryOK=true) { + return Credentials.setup(email, password).then((creds) => { + let data = { + authPW: CommonUtils.bytesAsHex(creds.authPW), + email: creds.emailUTF8, + }; + let keys = getKeys ? "?keys=true" : ""; + + return this._request("/account/login" + keys, "POST", null, data).then( + // Include the canonical capitalization of the email in the response so + // the caller can set its signed-in user state accordingly. + result => { + result.email = data.email; + result.unwrapBKey = CommonUtils.bytesAsHex(creds.unwrapBKey); + + return result; + }, + error => { + log.debug("signIn error: " + JSON.stringify(error)); + // If the user entered an email with different capitalization from + // what's stored in the database (e.g., Greta.Garbo@gmail.COM as + // opposed to greta.garbo@gmail.com), the server will respond with a + // errno 120 (code 400) and the expected capitalization of the email. + // We retry with this email exactly once. If successful, we use the + // server's version of the email as the signed-in-user's email. This + // is necessary because the email also serves as salt; so we must be + // in agreement with the server on capitalization. + // + // API reference: + // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md + if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) { + if (!error.email) { + log.error("Server returned errno 120 but did not provide email"); + throw error; + } + return this.signIn(error.email, password, getKeys, false); + } + throw error; + } + ); + }); + }, + + /** + * Destroy the current session with the Firefox Account API server + * + * @param sessionTokenHex + * The session token encoded in hex + * @return Promise + */ + signOut: function (sessionTokenHex) { + return this._request("/session/destroy", "POST", + this._deriveHawkCredentials(sessionTokenHex, "sessionToken")); + }, + + /** + * Check the verification status of the user's FxA email address + * + * @param sessionTokenHex + * The current session token encoded in hex + * @return Promise + */ + recoveryEmailStatus: function (sessionTokenHex) { + return this._request("/recovery_email/status", "GET", + this._deriveHawkCredentials(sessionTokenHex, "sessionToken")); + }, + + /** + * Resend the verification email for the user + * + * @param sessionTokenHex + * The current token encoded in hex + * @return Promise + */ + resendVerificationEmail: function(sessionTokenHex) { + return this._request("/recovery_email/resend_code", "POST", + this._deriveHawkCredentials(sessionTokenHex, "sessionToken")); + }, + + /** + * Retrieve encryption keys + * + * @param keyFetchTokenHex + * A one-time use key fetch token encoded in hex + * @return Promise + * Returns a promise that resolves to an object: + * { + * kA: an encryption key for recevorable data (bytes) + * wrapKB: an encryption key that requires knowledge of the + * user's password (bytes) + * } + */ + accountKeys: function (keyFetchTokenHex) { + let creds = this._deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken"); + let keyRequestKey = creds.extra.slice(0, 32); + let morecreds = CryptoUtils.hkdf(keyRequestKey, undefined, + Credentials.keyWord("account/keys"), 3 * 32); + let respHMACKey = morecreds.slice(0, 32); + let respXORKey = morecreds.slice(32, 96); + + return this._request("/account/keys", "GET", creds).then(resp => { + if (!resp.bundle) { + throw new Error("failed to retrieve keys"); + } + + let bundle = CommonUtils.hexToBytes(resp.bundle); + let mac = bundle.slice(-32); + + let hasher = CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256, + CryptoUtils.makeHMACKey(respHMACKey)); + + let bundleMAC = CryptoUtils.digestBytes(bundle.slice(0, -32), hasher); + if (mac !== bundleMAC) { + throw new Error("error unbundling encryption keys"); + } + + let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64)); + + return { + kA: keyAWrapB.slice(0, 32), + wrapKB: keyAWrapB.slice(32) + }; + }); + }, + + /** + * Sends a public key to the FxA API server and returns a signed certificate + * + * @param sessionTokenHex + * The current session token encoded in hex + * @param serializedPublicKey + * A public key (usually generated by jwcrypto) + * @param lifetime + * The lifetime of the certificate + * @return Promise + * Returns a promise that resolves to the signed certificate. The certificate + * can be used to generate a Persona assertion. + */ + signCertificate: function (sessionTokenHex, serializedPublicKey, lifetime) { + let creds = this._deriveHawkCredentials(sessionTokenHex, "sessionToken"); + + let body = { publicKey: serializedPublicKey, + duration: lifetime }; + return Promise.resolve() + .then(_ => this._request("/certificate/sign", "POST", creds, body)) + .then(resp => resp.cert, + err => { + log.error("HAWK.signCertificate error: " + JSON.stringify(err)); + throw err; + }); + }, + + /** + * Determine if an account exists + * + * @param email + * The email address to check + * @return Promise + * The promise resolves to true if the account exists, or false + * if it doesn't. The promise is rejected on other errors. + */ + accountExists: function (email) { + return this.signIn(email, "").then( + (cantHappen) => { + throw new Error("How did I sign in with an empty password?"); + }, + (expectedError) => { + switch (expectedError.errno) { + case ERRNO_ACCOUNT_DOES_NOT_EXIST: + return false; + break; + case ERRNO_INCORRECT_PASSWORD: + return true; + break; + default: + // not so expected, any more ... + throw expectedError; + break; + } + } + ); + }, + + /** + * The FxA auth server expects requests to certain endpoints to be authorized using Hawk. + * Hawk credentials are derived using shared secrets, which depend on the context + * (e.g. sessionToken vs. keyFetchToken). + * + * @param tokenHex + * The current session token encoded in hex + * @param context + * A context for the credentials + * @param size + * The size in bytes of the expected derived buffer + * @return credentials + * Returns an object: + * { + * algorithm: sha256 + * id: the Hawk id (from the first 32 bytes derived) + * key: the Hawk key (from bytes 32 to 64) + * extra: size - 64 extra bytes + * } + */ + _deriveHawkCredentials: function (tokenHex, context, size) { + let token = CommonUtils.hexToBytes(tokenHex); + let out = CryptoUtils.hkdf(token, undefined, Credentials.keyWord(context), size || 3 * 32); + + return { + algorithm: "sha256", + key: out.slice(32, 64), + extra: out.slice(64), + id: CommonUtils.bytesAsHex(out.slice(0, 32)) + }; + }, + + _clearBackoff: function() { + this.backoffError = null; + }, + + /** + * A general method for sending raw API calls to the FxA auth server. + * All request bodies and responses are JSON. + * + * @param path + * API endpoint path + * @param method + * The HTTP request method + * @param credentials + * Hawk credentials + * @param jsonPayload + * A JSON payload + * @return Promise + * Returns a promise that resolves to the JSON response of the API call, + * or is rejected with an error. Error responses have the following properties: + * { + * "code": 400, // matches the HTTP status code + * "errno": 107, // stable application-level error number + * "error": "Bad Request", // string description of the error type + * "message": "the value of salt is not allowed to be undefined", + * "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error + * } + */ + _request: function hawkRequest(path, method, credentials, jsonPayload) { + let deferred = Promise.defer(); + + // We were asked to back off. + if (this.backoffError) { + log.debug("Received new request during backoff, re-rejecting."); + deferred.reject(this.backoffError); + return deferred.promise; + } + + this.hawk.request(path, method, credentials, jsonPayload).then( + (responseText) => { + try { + let response = JSON.parse(responseText); + deferred.resolve(response); + } catch (err) { + log.error("json parse error on response: " + responseText); + deferred.reject({error: err}); + } + }, + + (error) => { + log.error("error " + method + "ing " + path + ": " + JSON.stringify(error)); + if (error.retryAfter) { + log.debug("Received backoff response; caching error as flag."); + this.backoffError = error; + // Schedule clearing of cached-error-as-flag. + CommonUtils.namedTimer( + this._clearBackoff, + error.retryAfter * 1000, + this, + "fxaBackoffTimer" + ); + } + deferred.reject(error); + } + ); + + return deferred.promise; + }, +}; +