services/fxaccounts/FxAccountsClient.jsm

branch
TOR_BUG_9701
changeset 15
b8a032363ba2
equal deleted inserted replaced
-1:000000000000 0:4020714cec30
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 this.EXPORTED_SYMBOLS = ["FxAccountsClient"];
6
7 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
8
9 Cu.import("resource://gre/modules/Log.jsm");
10 Cu.import("resource://gre/modules/Promise.jsm");
11 Cu.import("resource://gre/modules/Services.jsm");
12 Cu.import("resource://services-common/utils.js");
13 Cu.import("resource://services-common/hawkclient.js");
14 Cu.import("resource://services-crypto/utils.js");
15 Cu.import("resource://gre/modules/FxAccountsCommon.js");
16 Cu.import("resource://gre/modules/Credentials.jsm");
17
18 const HOST = Services.prefs.getCharPref("identity.fxaccounts.auth.uri");
19
20 this.FxAccountsClient = function(host = HOST) {
21 this.host = host;
22
23 // The FxA auth server expects requests to certain endpoints to be authorized
24 // using Hawk.
25 this.hawk = new HawkClient(host);
26 this.hawk.observerPrefix = "FxA:hawk";
27
28 // Manage server backoff state. C.f.
29 // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol
30 this.backoffError = null;
31 };
32
33 this.FxAccountsClient.prototype = {
34
35 /**
36 * Return client clock offset, in milliseconds, as determined by hawk client.
37 * Provided because callers should not have to know about hawk
38 * implementation.
39 *
40 * The offset is the number of milliseconds that must be added to the client
41 * clock to make it equal to the server clock. For example, if the client is
42 * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
43 */
44 get localtimeOffsetMsec() {
45 return this.hawk.localtimeOffsetMsec;
46 },
47
48 /*
49 * Return current time in milliseconds
50 *
51 * Not used by this module, but made available to the FxAccounts.jsm
52 * that uses this client.
53 */
54 now: function() {
55 return this.hawk.now();
56 },
57
58 /**
59 * Create a new Firefox Account and authenticate
60 *
61 * @param email
62 * The email address for the account (utf8)
63 * @param password
64 * The user's password
65 * @return Promise
66 * Returns a promise that resolves to an object:
67 * {
68 * uid: the user's unique ID (hex)
69 * sessionToken: a session token (hex)
70 * keyFetchToken: a key fetch token (hex)
71 * }
72 */
73 signUp: function(email, password) {
74 return Credentials.setup(email, password).then((creds) => {
75 let data = {
76 email: creds.emailUTF8,
77 authPW: CommonUtils.bytesAsHex(creds.authPW),
78 };
79 return this._request("/account/create", "POST", null, data);
80 });
81 },
82
83 /**
84 * Authenticate and create a new session with the Firefox Account API server
85 *
86 * @param email
87 * The email address for the account (utf8)
88 * @param password
89 * The user's password
90 * @param [getKeys=false]
91 * If set to true the keyFetchToken will be retrieved
92 * @param [retryOK=true]
93 * If capitalization of the email is wrong and retryOK is set to true,
94 * we will retry with the suggested capitalization from the server
95 * @return Promise
96 * Returns a promise that resolves to an object:
97 * {
98 * authAt: authentication time for the session (seconds since epoch)
99 * email: the primary email for this account
100 * keyFetchToken: a key fetch token (hex)
101 * sessionToken: a session token (hex)
102 * uid: the user's unique ID (hex)
103 * unwrapBKey: used to unwrap kB, derived locally from the
104 * password (not revealed to the FxA server)
105 * verified: flag indicating verification status of the email
106 * }
107 */
108 signIn: function signIn(email, password, getKeys=false, retryOK=true) {
109 return Credentials.setup(email, password).then((creds) => {
110 let data = {
111 authPW: CommonUtils.bytesAsHex(creds.authPW),
112 email: creds.emailUTF8,
113 };
114 let keys = getKeys ? "?keys=true" : "";
115
116 return this._request("/account/login" + keys, "POST", null, data).then(
117 // Include the canonical capitalization of the email in the response so
118 // the caller can set its signed-in user state accordingly.
119 result => {
120 result.email = data.email;
121 result.unwrapBKey = CommonUtils.bytesAsHex(creds.unwrapBKey);
122
123 return result;
124 },
125 error => {
126 log.debug("signIn error: " + JSON.stringify(error));
127 // If the user entered an email with different capitalization from
128 // what's stored in the database (e.g., Greta.Garbo@gmail.COM as
129 // opposed to greta.garbo@gmail.com), the server will respond with a
130 // errno 120 (code 400) and the expected capitalization of the email.
131 // We retry with this email exactly once. If successful, we use the
132 // server's version of the email as the signed-in-user's email. This
133 // is necessary because the email also serves as salt; so we must be
134 // in agreement with the server on capitalization.
135 //
136 // API reference:
137 // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md
138 if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) {
139 if (!error.email) {
140 log.error("Server returned errno 120 but did not provide email");
141 throw error;
142 }
143 return this.signIn(error.email, password, getKeys, false);
144 }
145 throw error;
146 }
147 );
148 });
149 },
150
151 /**
152 * Destroy the current session with the Firefox Account API server
153 *
154 * @param sessionTokenHex
155 * The session token encoded in hex
156 * @return Promise
157 */
158 signOut: function (sessionTokenHex) {
159 return this._request("/session/destroy", "POST",
160 this._deriveHawkCredentials(sessionTokenHex, "sessionToken"));
161 },
162
163 /**
164 * Check the verification status of the user's FxA email address
165 *
166 * @param sessionTokenHex
167 * The current session token encoded in hex
168 * @return Promise
169 */
170 recoveryEmailStatus: function (sessionTokenHex) {
171 return this._request("/recovery_email/status", "GET",
172 this._deriveHawkCredentials(sessionTokenHex, "sessionToken"));
173 },
174
175 /**
176 * Resend the verification email for the user
177 *
178 * @param sessionTokenHex
179 * The current token encoded in hex
180 * @return Promise
181 */
182 resendVerificationEmail: function(sessionTokenHex) {
183 return this._request("/recovery_email/resend_code", "POST",
184 this._deriveHawkCredentials(sessionTokenHex, "sessionToken"));
185 },
186
187 /**
188 * Retrieve encryption keys
189 *
190 * @param keyFetchTokenHex
191 * A one-time use key fetch token encoded in hex
192 * @return Promise
193 * Returns a promise that resolves to an object:
194 * {
195 * kA: an encryption key for recevorable data (bytes)
196 * wrapKB: an encryption key that requires knowledge of the
197 * user's password (bytes)
198 * }
199 */
200 accountKeys: function (keyFetchTokenHex) {
201 let creds = this._deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken");
202 let keyRequestKey = creds.extra.slice(0, 32);
203 let morecreds = CryptoUtils.hkdf(keyRequestKey, undefined,
204 Credentials.keyWord("account/keys"), 3 * 32);
205 let respHMACKey = morecreds.slice(0, 32);
206 let respXORKey = morecreds.slice(32, 96);
207
208 return this._request("/account/keys", "GET", creds).then(resp => {
209 if (!resp.bundle) {
210 throw new Error("failed to retrieve keys");
211 }
212
213 let bundle = CommonUtils.hexToBytes(resp.bundle);
214 let mac = bundle.slice(-32);
215
216 let hasher = CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256,
217 CryptoUtils.makeHMACKey(respHMACKey));
218
219 let bundleMAC = CryptoUtils.digestBytes(bundle.slice(0, -32), hasher);
220 if (mac !== bundleMAC) {
221 throw new Error("error unbundling encryption keys");
222 }
223
224 let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64));
225
226 return {
227 kA: keyAWrapB.slice(0, 32),
228 wrapKB: keyAWrapB.slice(32)
229 };
230 });
231 },
232
233 /**
234 * Sends a public key to the FxA API server and returns a signed certificate
235 *
236 * @param sessionTokenHex
237 * The current session token encoded in hex
238 * @param serializedPublicKey
239 * A public key (usually generated by jwcrypto)
240 * @param lifetime
241 * The lifetime of the certificate
242 * @return Promise
243 * Returns a promise that resolves to the signed certificate. The certificate
244 * can be used to generate a Persona assertion.
245 */
246 signCertificate: function (sessionTokenHex, serializedPublicKey, lifetime) {
247 let creds = this._deriveHawkCredentials(sessionTokenHex, "sessionToken");
248
249 let body = { publicKey: serializedPublicKey,
250 duration: lifetime };
251 return Promise.resolve()
252 .then(_ => this._request("/certificate/sign", "POST", creds, body))
253 .then(resp => resp.cert,
254 err => {
255 log.error("HAWK.signCertificate error: " + JSON.stringify(err));
256 throw err;
257 });
258 },
259
260 /**
261 * Determine if an account exists
262 *
263 * @param email
264 * The email address to check
265 * @return Promise
266 * The promise resolves to true if the account exists, or false
267 * if it doesn't. The promise is rejected on other errors.
268 */
269 accountExists: function (email) {
270 return this.signIn(email, "").then(
271 (cantHappen) => {
272 throw new Error("How did I sign in with an empty password?");
273 },
274 (expectedError) => {
275 switch (expectedError.errno) {
276 case ERRNO_ACCOUNT_DOES_NOT_EXIST:
277 return false;
278 break;
279 case ERRNO_INCORRECT_PASSWORD:
280 return true;
281 break;
282 default:
283 // not so expected, any more ...
284 throw expectedError;
285 break;
286 }
287 }
288 );
289 },
290
291 /**
292 * The FxA auth server expects requests to certain endpoints to be authorized using Hawk.
293 * Hawk credentials are derived using shared secrets, which depend on the context
294 * (e.g. sessionToken vs. keyFetchToken).
295 *
296 * @param tokenHex
297 * The current session token encoded in hex
298 * @param context
299 * A context for the credentials
300 * @param size
301 * The size in bytes of the expected derived buffer
302 * @return credentials
303 * Returns an object:
304 * {
305 * algorithm: sha256
306 * id: the Hawk id (from the first 32 bytes derived)
307 * key: the Hawk key (from bytes 32 to 64)
308 * extra: size - 64 extra bytes
309 * }
310 */
311 _deriveHawkCredentials: function (tokenHex, context, size) {
312 let token = CommonUtils.hexToBytes(tokenHex);
313 let out = CryptoUtils.hkdf(token, undefined, Credentials.keyWord(context), size || 3 * 32);
314
315 return {
316 algorithm: "sha256",
317 key: out.slice(32, 64),
318 extra: out.slice(64),
319 id: CommonUtils.bytesAsHex(out.slice(0, 32))
320 };
321 },
322
323 _clearBackoff: function() {
324 this.backoffError = null;
325 },
326
327 /**
328 * A general method for sending raw API calls to the FxA auth server.
329 * All request bodies and responses are JSON.
330 *
331 * @param path
332 * API endpoint path
333 * @param method
334 * The HTTP request method
335 * @param credentials
336 * Hawk credentials
337 * @param jsonPayload
338 * A JSON payload
339 * @return Promise
340 * Returns a promise that resolves to the JSON response of the API call,
341 * or is rejected with an error. Error responses have the following properties:
342 * {
343 * "code": 400, // matches the HTTP status code
344 * "errno": 107, // stable application-level error number
345 * "error": "Bad Request", // string description of the error type
346 * "message": "the value of salt is not allowed to be undefined",
347 * "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error
348 * }
349 */
350 _request: function hawkRequest(path, method, credentials, jsonPayload) {
351 let deferred = Promise.defer();
352
353 // We were asked to back off.
354 if (this.backoffError) {
355 log.debug("Received new request during backoff, re-rejecting.");
356 deferred.reject(this.backoffError);
357 return deferred.promise;
358 }
359
360 this.hawk.request(path, method, credentials, jsonPayload).then(
361 (responseText) => {
362 try {
363 let response = JSON.parse(responseText);
364 deferred.resolve(response);
365 } catch (err) {
366 log.error("json parse error on response: " + responseText);
367 deferred.reject({error: err});
368 }
369 },
370
371 (error) => {
372 log.error("error " + method + "ing " + path + ": " + JSON.stringify(error));
373 if (error.retryAfter) {
374 log.debug("Received backoff response; caching error as flag.");
375 this.backoffError = error;
376 // Schedule clearing of cached-error-as-flag.
377 CommonUtils.namedTimer(
378 this._clearBackoff,
379 error.retryAfter * 1000,
380 this,
381 "fxaBackoffTimer"
382 );
383 }
384 deferred.reject(error);
385 }
386 );
387
388 return deferred.promise;
389 },
390 };
391

mercurial