services/fxaccounts/FxAccountsClient.jsm

changeset 0
6474c204b198
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/services/fxaccounts/FxAccountsClient.jsm	Wed Dec 31 06:09:35 2014 +0100
     1.3 @@ -0,0 +1,391 @@
     1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public
     1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this
     1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     1.7 +
     1.8 +this.EXPORTED_SYMBOLS = ["FxAccountsClient"];
     1.9 +
    1.10 +const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
    1.11 +
    1.12 +Cu.import("resource://gre/modules/Log.jsm");
    1.13 +Cu.import("resource://gre/modules/Promise.jsm");
    1.14 +Cu.import("resource://gre/modules/Services.jsm");
    1.15 +Cu.import("resource://services-common/utils.js");
    1.16 +Cu.import("resource://services-common/hawkclient.js");
    1.17 +Cu.import("resource://services-crypto/utils.js");
    1.18 +Cu.import("resource://gre/modules/FxAccountsCommon.js");
    1.19 +Cu.import("resource://gre/modules/Credentials.jsm");
    1.20 +
    1.21 +const HOST = Services.prefs.getCharPref("identity.fxaccounts.auth.uri");
    1.22 +
    1.23 +this.FxAccountsClient = function(host = HOST) {
    1.24 +  this.host = host;
    1.25 +
    1.26 +  // The FxA auth server expects requests to certain endpoints to be authorized
    1.27 +  // using Hawk.
    1.28 +  this.hawk = new HawkClient(host);
    1.29 +  this.hawk.observerPrefix = "FxA:hawk";
    1.30 +
    1.31 +  // Manage server backoff state. C.f.
    1.32 +  // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol
    1.33 +  this.backoffError = null;
    1.34 +};
    1.35 +
    1.36 +this.FxAccountsClient.prototype = {
    1.37 +
    1.38 +  /**
    1.39 +   * Return client clock offset, in milliseconds, as determined by hawk client.
    1.40 +   * Provided because callers should not have to know about hawk
    1.41 +   * implementation.
    1.42 +   *
    1.43 +   * The offset is the number of milliseconds that must be added to the client
    1.44 +   * clock to make it equal to the server clock.  For example, if the client is
    1.45 +   * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
    1.46 +   */
    1.47 +  get localtimeOffsetMsec() {
    1.48 +    return this.hawk.localtimeOffsetMsec;
    1.49 +  },
    1.50 +
    1.51 +  /*
    1.52 +   * Return current time in milliseconds
    1.53 +   *
    1.54 +   * Not used by this module, but made available to the FxAccounts.jsm
    1.55 +   * that uses this client.
    1.56 +   */
    1.57 +  now: function() {
    1.58 +    return this.hawk.now();
    1.59 +  },
    1.60 +
    1.61 +  /**
    1.62 +   * Create a new Firefox Account and authenticate
    1.63 +   *
    1.64 +   * @param email
    1.65 +   *        The email address for the account (utf8)
    1.66 +   * @param password
    1.67 +   *        The user's password
    1.68 +   * @return Promise
    1.69 +   *        Returns a promise that resolves to an object:
    1.70 +   *        {
    1.71 +   *          uid: the user's unique ID (hex)
    1.72 +   *          sessionToken: a session token (hex)
    1.73 +   *          keyFetchToken: a key fetch token (hex)
    1.74 +   *        }
    1.75 +   */
    1.76 +  signUp: function(email, password) {
    1.77 +    return Credentials.setup(email, password).then((creds) => {
    1.78 +      let data = {
    1.79 +        email: creds.emailUTF8,
    1.80 +        authPW: CommonUtils.bytesAsHex(creds.authPW),
    1.81 +      };
    1.82 +      return this._request("/account/create", "POST", null, data);
    1.83 +    });
    1.84 +  },
    1.85 +
    1.86 +  /**
    1.87 +   * Authenticate and create a new session with the Firefox Account API server
    1.88 +   *
    1.89 +   * @param email
    1.90 +   *        The email address for the account (utf8)
    1.91 +   * @param password
    1.92 +   *        The user's password
    1.93 +   * @param [getKeys=false]
    1.94 +   *        If set to true the keyFetchToken will be retrieved
    1.95 +   * @param [retryOK=true]
    1.96 +   *        If capitalization of the email is wrong and retryOK is set to true,
    1.97 +   *        we will retry with the suggested capitalization from the server
    1.98 +   * @return Promise
    1.99 +   *        Returns a promise that resolves to an object:
   1.100 +   *        {
   1.101 +   *          authAt: authentication time for the session (seconds since epoch)
   1.102 +   *          email: the primary email for this account
   1.103 +   *          keyFetchToken: a key fetch token (hex)
   1.104 +   *          sessionToken: a session token (hex)
   1.105 +   *          uid: the user's unique ID (hex)
   1.106 +   *          unwrapBKey: used to unwrap kB, derived locally from the
   1.107 +   *                      password (not revealed to the FxA server)
   1.108 +   *          verified: flag indicating verification status of the email
   1.109 +   *        }
   1.110 +   */
   1.111 +  signIn: function signIn(email, password, getKeys=false, retryOK=true) {
   1.112 +    return Credentials.setup(email, password).then((creds) => {
   1.113 +      let data = {
   1.114 +        authPW: CommonUtils.bytesAsHex(creds.authPW),
   1.115 +        email: creds.emailUTF8,
   1.116 +      };
   1.117 +      let keys = getKeys ? "?keys=true" : "";
   1.118 +
   1.119 +      return this._request("/account/login" + keys, "POST", null, data).then(
   1.120 +        // Include the canonical capitalization of the email in the response so
   1.121 +        // the caller can set its signed-in user state accordingly.
   1.122 +        result => {
   1.123 +          result.email = data.email;
   1.124 +          result.unwrapBKey = CommonUtils.bytesAsHex(creds.unwrapBKey);
   1.125 +
   1.126 +          return result;
   1.127 +        },
   1.128 +        error => {
   1.129 +          log.debug("signIn error: " + JSON.stringify(error));
   1.130 +          // If the user entered an email with different capitalization from
   1.131 +          // what's stored in the database (e.g., Greta.Garbo@gmail.COM as
   1.132 +          // opposed to greta.garbo@gmail.com), the server will respond with a
   1.133 +          // errno 120 (code 400) and the expected capitalization of the email.
   1.134 +          // We retry with this email exactly once.  If successful, we use the
   1.135 +          // server's version of the email as the signed-in-user's email. This
   1.136 +          // is necessary because the email also serves as salt; so we must be
   1.137 +          // in agreement with the server on capitalization.
   1.138 +          //
   1.139 +          // API reference:
   1.140 +          // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md
   1.141 +          if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) {
   1.142 +            if (!error.email) {
   1.143 +              log.error("Server returned errno 120 but did not provide email");
   1.144 +              throw error;
   1.145 +            }
   1.146 +            return this.signIn(error.email, password, getKeys, false);
   1.147 +          }
   1.148 +          throw error;
   1.149 +        }
   1.150 +      );
   1.151 +    });
   1.152 +  },
   1.153 +
   1.154 +  /**
   1.155 +   * Destroy the current session with the Firefox Account API server
   1.156 +   *
   1.157 +   * @param sessionTokenHex
   1.158 +   *        The session token encoded in hex
   1.159 +   * @return Promise
   1.160 +   */
   1.161 +  signOut: function (sessionTokenHex) {
   1.162 +    return this._request("/session/destroy", "POST",
   1.163 +      this._deriveHawkCredentials(sessionTokenHex, "sessionToken"));
   1.164 +  },
   1.165 +
   1.166 +  /**
   1.167 +   * Check the verification status of the user's FxA email address
   1.168 +   *
   1.169 +   * @param sessionTokenHex
   1.170 +   *        The current session token encoded in hex
   1.171 +   * @return Promise
   1.172 +   */
   1.173 +  recoveryEmailStatus: function (sessionTokenHex) {
   1.174 +    return this._request("/recovery_email/status", "GET",
   1.175 +      this._deriveHawkCredentials(sessionTokenHex, "sessionToken"));
   1.176 +  },
   1.177 +
   1.178 +  /**
   1.179 +   * Resend the verification email for the user
   1.180 +   *
   1.181 +   * @param sessionTokenHex
   1.182 +   *        The current token encoded in hex
   1.183 +   * @return Promise
   1.184 +   */
   1.185 +  resendVerificationEmail: function(sessionTokenHex) {
   1.186 +    return this._request("/recovery_email/resend_code", "POST",
   1.187 +      this._deriveHawkCredentials(sessionTokenHex, "sessionToken"));
   1.188 +  },
   1.189 +
   1.190 +  /**
   1.191 +   * Retrieve encryption keys
   1.192 +   *
   1.193 +   * @param keyFetchTokenHex
   1.194 +   *        A one-time use key fetch token encoded in hex
   1.195 +   * @return Promise
   1.196 +   *        Returns a promise that resolves to an object:
   1.197 +   *        {
   1.198 +   *          kA: an encryption key for recevorable data (bytes)
   1.199 +   *          wrapKB: an encryption key that requires knowledge of the
   1.200 +   *                  user's password (bytes)
   1.201 +   *        }
   1.202 +   */
   1.203 +  accountKeys: function (keyFetchTokenHex) {
   1.204 +    let creds = this._deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken");
   1.205 +    let keyRequestKey = creds.extra.slice(0, 32);
   1.206 +    let morecreds = CryptoUtils.hkdf(keyRequestKey, undefined,
   1.207 +                                     Credentials.keyWord("account/keys"), 3 * 32);
   1.208 +    let respHMACKey = morecreds.slice(0, 32);
   1.209 +    let respXORKey = morecreds.slice(32, 96);
   1.210 +
   1.211 +    return this._request("/account/keys", "GET", creds).then(resp => {
   1.212 +      if (!resp.bundle) {
   1.213 +        throw new Error("failed to retrieve keys");
   1.214 +      }
   1.215 +
   1.216 +      let bundle = CommonUtils.hexToBytes(resp.bundle);
   1.217 +      let mac = bundle.slice(-32);
   1.218 +
   1.219 +      let hasher = CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256,
   1.220 +        CryptoUtils.makeHMACKey(respHMACKey));
   1.221 +
   1.222 +      let bundleMAC = CryptoUtils.digestBytes(bundle.slice(0, -32), hasher);
   1.223 +      if (mac !== bundleMAC) {
   1.224 +        throw new Error("error unbundling encryption keys");
   1.225 +      }
   1.226 +
   1.227 +      let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64));
   1.228 +
   1.229 +      return {
   1.230 +        kA: keyAWrapB.slice(0, 32),
   1.231 +        wrapKB: keyAWrapB.slice(32)
   1.232 +      };
   1.233 +    });
   1.234 +  },
   1.235 +
   1.236 +  /**
   1.237 +   * Sends a public key to the FxA API server and returns a signed certificate
   1.238 +   *
   1.239 +   * @param sessionTokenHex
   1.240 +   *        The current session token encoded in hex
   1.241 +   * @param serializedPublicKey
   1.242 +   *        A public key (usually generated by jwcrypto)
   1.243 +   * @param lifetime
   1.244 +   *        The lifetime of the certificate
   1.245 +   * @return Promise
   1.246 +   *        Returns a promise that resolves to the signed certificate. The certificate
   1.247 +   *        can be used to generate a Persona assertion.
   1.248 +   */
   1.249 +  signCertificate: function (sessionTokenHex, serializedPublicKey, lifetime) {
   1.250 +    let creds = this._deriveHawkCredentials(sessionTokenHex, "sessionToken");
   1.251 +
   1.252 +    let body = { publicKey: serializedPublicKey,
   1.253 +                 duration: lifetime };
   1.254 +    return Promise.resolve()
   1.255 +      .then(_ => this._request("/certificate/sign", "POST", creds, body))
   1.256 +      .then(resp => resp.cert,
   1.257 +            err => {
   1.258 +              log.error("HAWK.signCertificate error: " + JSON.stringify(err));
   1.259 +              throw err;
   1.260 +            });
   1.261 +  },
   1.262 +
   1.263 +  /**
   1.264 +   * Determine if an account exists
   1.265 +   *
   1.266 +   * @param email
   1.267 +   *        The email address to check
   1.268 +   * @return Promise
   1.269 +   *        The promise resolves to true if the account exists, or false
   1.270 +   *        if it doesn't. The promise is rejected on other errors.
   1.271 +   */
   1.272 +  accountExists: function (email) {
   1.273 +    return this.signIn(email, "").then(
   1.274 +      (cantHappen) => {
   1.275 +        throw new Error("How did I sign in with an empty password?");
   1.276 +      },
   1.277 +      (expectedError) => {
   1.278 +        switch (expectedError.errno) {
   1.279 +          case ERRNO_ACCOUNT_DOES_NOT_EXIST:
   1.280 +            return false;
   1.281 +            break;
   1.282 +          case ERRNO_INCORRECT_PASSWORD:
   1.283 +            return true;
   1.284 +            break;
   1.285 +          default:
   1.286 +            // not so expected, any more ...
   1.287 +            throw expectedError;
   1.288 +            break;
   1.289 +        }
   1.290 +      }
   1.291 +    );
   1.292 +  },
   1.293 +
   1.294 +  /**
   1.295 +   * The FxA auth server expects requests to certain endpoints to be authorized using Hawk.
   1.296 +   * Hawk credentials are derived using shared secrets, which depend on the context
   1.297 +   * (e.g. sessionToken vs. keyFetchToken).
   1.298 +   *
   1.299 +   * @param tokenHex
   1.300 +   *        The current session token encoded in hex
   1.301 +   * @param context
   1.302 +   *        A context for the credentials
   1.303 +   * @param size
   1.304 +   *        The size in bytes of the expected derived buffer
   1.305 +   * @return credentials
   1.306 +   *        Returns an object:
   1.307 +   *        {
   1.308 +   *          algorithm: sha256
   1.309 +   *          id: the Hawk id (from the first 32 bytes derived)
   1.310 +   *          key: the Hawk key (from bytes 32 to 64)
   1.311 +   *          extra: size - 64 extra bytes
   1.312 +   *        }
   1.313 +   */
   1.314 +  _deriveHawkCredentials: function (tokenHex, context, size) {
   1.315 +    let token = CommonUtils.hexToBytes(tokenHex);
   1.316 +    let out = CryptoUtils.hkdf(token, undefined, Credentials.keyWord(context), size || 3 * 32);
   1.317 +
   1.318 +    return {
   1.319 +      algorithm: "sha256",
   1.320 +      key: out.slice(32, 64),
   1.321 +      extra: out.slice(64),
   1.322 +      id: CommonUtils.bytesAsHex(out.slice(0, 32))
   1.323 +    };
   1.324 +  },
   1.325 +
   1.326 +  _clearBackoff: function() {
   1.327 +      this.backoffError = null;
   1.328 +  },
   1.329 +
   1.330 +  /**
   1.331 +   * A general method for sending raw API calls to the FxA auth server.
   1.332 +   * All request bodies and responses are JSON.
   1.333 +   *
   1.334 +   * @param path
   1.335 +   *        API endpoint path
   1.336 +   * @param method
   1.337 +   *        The HTTP request method
   1.338 +   * @param credentials
   1.339 +   *        Hawk credentials
   1.340 +   * @param jsonPayload
   1.341 +   *        A JSON payload
   1.342 +   * @return Promise
   1.343 +   *        Returns a promise that resolves to the JSON response of the API call,
   1.344 +   *        or is rejected with an error. Error responses have the following properties:
   1.345 +   *        {
   1.346 +   *          "code": 400, // matches the HTTP status code
   1.347 +   *          "errno": 107, // stable application-level error number
   1.348 +   *          "error": "Bad Request", // string description of the error type
   1.349 +   *          "message": "the value of salt is not allowed to be undefined",
   1.350 +   *          "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error
   1.351 +   *        }
   1.352 +   */
   1.353 +  _request: function hawkRequest(path, method, credentials, jsonPayload) {
   1.354 +    let deferred = Promise.defer();
   1.355 +
   1.356 +    // We were asked to back off.
   1.357 +    if (this.backoffError) {
   1.358 +      log.debug("Received new request during backoff, re-rejecting.");
   1.359 +      deferred.reject(this.backoffError);
   1.360 +      return deferred.promise;
   1.361 +    }
   1.362 +
   1.363 +    this.hawk.request(path, method, credentials, jsonPayload).then(
   1.364 +      (responseText) => {
   1.365 +        try {
   1.366 +          let response = JSON.parse(responseText);
   1.367 +          deferred.resolve(response);
   1.368 +        } catch (err) {
   1.369 +          log.error("json parse error on response: " + responseText);
   1.370 +          deferred.reject({error: err});
   1.371 +        }
   1.372 +      },
   1.373 +
   1.374 +      (error) => {
   1.375 +        log.error("error " + method + "ing " + path + ": " + JSON.stringify(error));
   1.376 +        if (error.retryAfter) {
   1.377 +          log.debug("Received backoff response; caching error as flag.");
   1.378 +          this.backoffError = error;
   1.379 +          // Schedule clearing of cached-error-as-flag.
   1.380 +          CommonUtils.namedTimer(
   1.381 +            this._clearBackoff,
   1.382 +            error.retryAfter * 1000,
   1.383 +            this,
   1.384 +            "fxaBackoffTimer"
   1.385 +           );
   1.386 +	}
   1.387 +        deferred.reject(error);
   1.388 +      }
   1.389 +    );
   1.390 +
   1.391 +    return deferred.promise;
   1.392 +  },
   1.393 +};
   1.394 +

mercurial