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 +