Upstream version 8.37.180.0
[platform/framework/web/crosswalk.git] / src / chrome / browser / resources / cryptotoken / enroller.js
1 // Copyright 2014 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4
5 /**
6  * @fileoverview Handles web page requests for gnubby enrollment.
7  */
8
9 'use strict';
10
11 /**
12  * Handles an enroll request.
13  * @param {!EnrollHelperFactory} factory Factory to create an enroll helper.
14  * @param {MessageSender} sender The sender of the message.
15  * @param {Object} request The web page's enroll request.
16  * @param {Function} sendResponse Called back with the result of the enroll.
17  * @param {boolean} toleratesMultipleResponses Whether the sendResponse
18  *     callback can be called more than once, e.g. for progress updates.
19  * @return {Closeable} A handler object to be closed when the browser channel
20  *     closes.
21  */
22 function handleEnrollRequest(factory, sender, request, sendResponse,
23     toleratesMultipleResponses) {
24   var sentResponse = false;
25   function sendResponseOnce(r) {
26     if (enroller) {
27       enroller.close();
28       enroller = null;
29     }
30     if (!sentResponse) {
31       sentResponse = true;
32       try {
33         // If the page has gone away or the connection has otherwise gone,
34         // sendResponse fails.
35         sendResponse(r);
36       } catch (exception) {
37         console.warn('sendResponse failed: ' + exception);
38       }
39     } else {
40       console.warn(UTIL_fmt('Tried to reply more than once! Juan, FIX ME'));
41     }
42   }
43
44   function sendErrorResponse(code) {
45     console.log(UTIL_fmt('code=' + code));
46     var response = formatWebPageResponse(GnubbyMsgTypes.ENROLL_WEB_REPLY, code);
47     if (request['requestId']) {
48       response['requestId'] = request['requestId'];
49     }
50     sendResponseOnce(response);
51   }
52
53   var origin = getOriginFromUrl(/** @type {string} */ (sender.url));
54   if (!origin) {
55     sendErrorResponse(GnubbyCodeTypes.BAD_REQUEST);
56     return null;
57   }
58
59   if (!isValidEnrollRequest(request)) {
60     sendErrorResponse(GnubbyCodeTypes.BAD_REQUEST);
61     return null;
62   }
63
64   var signData = request['signData'];
65   var enrollChallenges = request['enrollChallenges'];
66   var logMsgUrl = request['logMsgUrl'];
67   var timeoutMillis = Enroller.DEFAULT_TIMEOUT_MILLIS;
68   if (request['timeout']) {
69     // Request timeout is in seconds.
70     timeoutMillis = request['timeout'] * 1000;
71   }
72
73   function findChallengeOfVersion(enrollChallenges, version) {
74     for (var i = 0; i < enrollChallenges.length; i++) {
75       if (enrollChallenges[i]['version'] == version) {
76         return enrollChallenges[i];
77       }
78     }
79     return null;
80   }
81
82   function sendSuccessResponse(u2fVersion, info, browserData) {
83     var enrollChallenge = findChallengeOfVersion(enrollChallenges, u2fVersion);
84     if (!enrollChallenge) {
85       sendErrorResponse(GnubbyCodeTypes.UNKNOWN_ERROR);
86       return;
87     }
88     var enrollUpdateData = {};
89     enrollUpdateData['enrollData'] = info;
90     // Echo the used challenge back in the reply.
91     for (var k in enrollChallenge) {
92       enrollUpdateData[k] = enrollChallenge[k];
93     }
94     if (u2fVersion == 'U2F_V2') {
95       // For U2F_V2, the challenge sent to the gnubby is modified to be the
96       // hash of the browser data. Include the browser data.
97       enrollUpdateData['browserData'] = browserData;
98     }
99     var response = formatWebPageResponse(
100         GnubbyMsgTypes.ENROLL_WEB_REPLY, GnubbyCodeTypes.OK, enrollUpdateData);
101     sendResponseOnce(response);
102   }
103
104   function sendNotification(code) {
105     console.log(UTIL_fmt('notification, code=' + code));
106     // Can the callback handle progress updates? If so, send one.
107     if (toleratesMultipleResponses) {
108       var response = formatWebPageResponse(
109           GnubbyMsgTypes.ENROLL_WEB_NOTIFICATION, code);
110       if (request['requestId']) {
111         response['requestId'] = request['requestId'];
112       }
113       sendResponse(response);
114     }
115   }
116
117   var timer = new CountdownTimer(timeoutMillis);
118   var enroller = new Enroller(factory, timer, origin, sendErrorResponse,
119       sendSuccessResponse, sendNotification, sender.tlsChannelId, logMsgUrl);
120   enroller.doEnroll(enrollChallenges, signData);
121   return /** @type {Closeable} */ (enroller);
122 }
123
124 /**
125  * Returns whether the request appears to be a valid enroll request.
126  * @param {Object} request the request.
127  * @return {boolean} whether the request appears valid.
128  */
129 function isValidEnrollRequest(request) {
130   if (!request.hasOwnProperty('enrollChallenges'))
131     return false;
132   var enrollChallenges = request['enrollChallenges'];
133   if (!enrollChallenges.length)
134     return false;
135   var seenVersions = {};
136   for (var i = 0; i < enrollChallenges.length; i++) {
137     var enrollChallenge = enrollChallenges[i];
138     var version = enrollChallenge['version'];
139     if (!version) {
140       // Version is implicitly V1 if not specified.
141       version = 'U2F_V1';
142     }
143     if (version != 'U2F_V1' && version != 'U2F_V2') {
144       return false;
145     }
146     if (seenVersions[version]) {
147       // Each version can appear at most once.
148       return false;
149     }
150     seenVersions[version] = version;
151     if (!enrollChallenge['appId']) {
152       return false;
153     }
154     if (!enrollChallenge['challenge']) {
155       // The challenge is required.
156       return false;
157     }
158   }
159   var signData = request['signData'];
160   // An empty signData is ok, in the case the user is not already enrolled.
161   if (signData && !isValidSignData(signData))
162     return false;
163   return true;
164 }
165
166 /**
167  * Creates a new object to track enrolling with a gnubby.
168  * @param {!EnrollHelperFactory} helperFactory factory to create an enroll
169  *     helper.
170  * @param {!Countdown} timer Timer for enroll request.
171  * @param {string} origin The origin making the request.
172  * @param {function(number)} errorCb Called upon enroll failure with an error
173  *     code.
174  * @param {function(string, string, (string|undefined))} successCb Called upon
175  *     enroll success with the version of the succeeding gnubby, the enroll
176  *     data, and optionally the browser data associated with the enrollment.
177  * @param {(function(number)|undefined)} opt_progressCb Called with progress
178  *     updates to the enroll request.
179  * @param {string=} opt_tlsChannelId the TLS channel ID, if any, of the origin
180  *     making the request.
181  * @param {string=} opt_logMsgUrl The url to post log messages to.
182  * @constructor
183  */
184 function Enroller(helperFactory, timer, origin, errorCb, successCb,
185     opt_progressCb, opt_tlsChannelId, opt_logMsgUrl) {
186   /** @private {Countdown} */
187   this.timer_ = timer;
188   /** @private {string} */
189   this.origin_ = origin;
190   /** @private {function(number)} */
191   this.errorCb_ = errorCb;
192   /** @private {function(string, string, (string|undefined))} */
193   this.successCb_ = successCb;
194   /** @private {(function(number)|undefined)} */
195   this.progressCb_ = opt_progressCb;
196   /** @private {string|undefined} */
197   this.tlsChannelId_ = opt_tlsChannelId;
198   /** @private {string|undefined} */
199   this.logMsgUrl_ = opt_logMsgUrl;
200
201   /** @private {boolean} */
202   this.done_ = false;
203   /** @private {number|undefined} */
204   this.lastProgressUpdate_ = undefined;
205
206   /** @private {Object.<string, string>} */
207   this.browserData_ = {};
208   /** @private {Array.<EnrollHelperChallenge>} */
209   this.encodedEnrollChallenges_ = [];
210   /** @private {Array.<SignHelperChallenge>} */
211   this.encodedSignChallenges_ = [];
212   // Allow http appIds for http origins. (Broken, but the caller deserves
213   // what they get.)
214   /** @private {boolean} */
215   this.allowHttp_ = this.origin_ ? this.origin_.indexOf('http://') == 0 : false;
216
217   /** @private {EnrollHelper} */
218   this.helper_ = helperFactory.createHelper(timer,
219       this.helperError_.bind(this), this.helperSuccess_.bind(this),
220       this.helperProgress_.bind(this));
221 }
222
223 /**
224  * Default timeout value in case the caller never provides a valid timeout.
225  */
226 Enroller.DEFAULT_TIMEOUT_MILLIS = 30 * 1000;
227
228 /**
229  * Performs an enroll request with the given enroll and sign challenges.
230  * @param {Array.<Object>} enrollChallenges A set of enroll challenges
231  * @param {Array.<Object>} signChallenges A set of sign challenges for existing
232  *     enrollments for this user and appId
233  */
234 Enroller.prototype.doEnroll = function(enrollChallenges, signChallenges) {
235   this.setEnrollChallenges_(enrollChallenges);
236   this.setSignChallenges_(signChallenges);
237
238   // Begin fetching/checking the app ids.
239   var enrollAppIds = [];
240   for (var i = 0; i < enrollChallenges.length; i++) {
241     enrollAppIds.push(enrollChallenges[i]['appId']);
242   }
243   var self = this;
244   this.checkAppIds_(enrollAppIds, signChallenges, function(result) {
245     if (result) {
246       self.helper_.doEnroll(self.encodedEnrollChallenges_,
247           self.encodedSignChallenges_);
248     } else {
249       self.notifyError_(GnubbyCodeTypes.BAD_APP_ID);
250     }
251   });
252 };
253
254 /**
255  * Encodes the enroll challenges for use by an enroll helper.
256  * @param {Array.<Object>} enrollChallenges A set of enroll challenges
257  * @return {Array.<EnrollHelperChallenge>} the encoded challenges.
258  * @private
259  */
260 Enroller.encodeEnrollChallenges_ = function(enrollChallenges) {
261   var encodedChallenges = [];
262   for (var i = 0; i < enrollChallenges.length; i++) {
263     var enrollChallenge = enrollChallenges[i];
264     var encodedChallenge = {};
265     var version;
266     if (enrollChallenge['version']) {
267       version = enrollChallenge['version'];
268     } else {
269       // Version is implicitly V1 if not specified.
270       version = 'U2F_V1';
271     }
272     encodedChallenge['version'] = version;
273     encodedChallenge['challenge'] = enrollChallenge['challenge'];
274     encodedChallenge['appIdHash'] =
275         B64_encode(sha256HashOfString(enrollChallenge['appId']));
276     encodedChallenges.push(encodedChallenge);
277   }
278   return encodedChallenges;
279 };
280
281 /**
282  * Sets this enroller's enroll challenges.
283  * @param {Array.<Object>} enrollChallenges The enroll challenges.
284  * @private
285  */
286 Enroller.prototype.setEnrollChallenges_ = function(enrollChallenges) {
287   var challenges = [];
288   for (var i = 0; i < enrollChallenges.length; i++) {
289     var enrollChallenge = enrollChallenges[i];
290     var version = enrollChallenge.version;
291     if (!version) {
292       // Version is implicitly V1 if not specified.
293       version = 'U2F_V1';
294     }
295
296     if (version == 'U2F_V2') {
297       var modifiedChallenge = {};
298       for (var k in enrollChallenge) {
299         modifiedChallenge[k] = enrollChallenge[k];
300       }
301       // V2 enroll responses contain signatures over a browser data object,
302       // which we're constructing here. The browser data object contains, among
303       // other things, the server challenge.
304       var serverChallenge = enrollChallenge['challenge'];
305       var browserData = makeEnrollBrowserData(
306           serverChallenge, this.origin_, this.tlsChannelId_);
307       // Replace the challenge with the hash of the browser data.
308       modifiedChallenge['challenge'] =
309           B64_encode(sha256HashOfString(browserData));
310       this.browserData_[version] =
311           B64_encode(UTIL_StringToBytes(browserData));
312       challenges.push(modifiedChallenge);
313     } else {
314       challenges.push(enrollChallenge);
315     }
316   }
317   // Store the encoded challenges for use by the enroll helper.
318   this.encodedEnrollChallenges_ =
319       Enroller.encodeEnrollChallenges_(challenges);
320 };
321
322 /**
323  * Sets this enroller's sign data.
324  * @param {Array=} signData the sign challenges to add.
325  * @private
326  */
327 Enroller.prototype.setSignChallenges_ = function(signData) {
328   this.encodedSignChallenges_ = [];
329   if (signData) {
330     for (var i = 0; i < signData.length; i++) {
331       var incomingChallenge = signData[i];
332       var serverChallenge = incomingChallenge['challenge'];
333       var appId = incomingChallenge['appId'];
334       var encodedKeyHandle = incomingChallenge['keyHandle'];
335
336       var challenge = makeChallenge(serverChallenge, appId, encodedKeyHandle,
337           incomingChallenge['version']);
338
339       this.encodedSignChallenges_.push(challenge);
340     }
341   }
342 };
343
344 /**
345  * Checks the app ids associated with this enroll request, and calls a callback
346  * with the result of the check.
347  * @param {!Array.<string>} enrollAppIds The app ids in the enroll challenge
348  *     portion of the enroll request.
349  * @param {SignData} signData The sign data associated with the request.
350  * @param {function(boolean)} cb Called with the result of the check.
351  * @private
352  */
353 Enroller.prototype.checkAppIds_ = function(enrollAppIds, signData, cb) {
354   if (!enrollAppIds || !enrollAppIds.length) {
355     // Defensive programming check: the enroll request is required to contain
356     // its own app ids, so if there aren't any, reject the request.
357     cb(false);
358     return;
359   }
360
361   /** @private {Array.<string>} */
362   this.distinctAppIds_ =
363       UTIL_unionArrays(enrollAppIds, getDistinctAppIds(signData));
364   /** @private {boolean} */
365   this.anyInvalidAppIds_ = false;
366   /** @private {boolean} */
367   this.appIdFailureReported_ = false;
368   /** @private {number} */
369   this.fetchedAppIds_ = 0;
370
371   for (var i = 0; i < this.distinctAppIds_.length; i++) {
372     var appId = this.distinctAppIds_[i];
373     if (appId == this.origin_) {
374       // Trivially allowed.
375       this.fetchedAppIds_++;
376       if (this.fetchedAppIds_ == this.distinctAppIds_.length &&
377           !this.anyInvalidAppIds_) {
378         // Last app id was fetched, and they were all valid: we're done.
379         // (Note that the case when anyInvalidAppIds_ is true doesn't need to
380         // be handled here: the callback was already called with false at that
381         // point, see fetchedAllowedOriginsForAppId_.)
382         cb(true);
383       }
384     } else {
385       var start = new Date();
386       fetchAllowedOriginsForAppId(appId, this.allowHttp_,
387           this.fetchedAllowedOriginsForAppId_.bind(this, appId, start, cb));
388     }
389   }
390 };
391
392 /**
393  * Called with the result of an app id fetch.
394  * @param {string} appId the app id that was fetched.
395  * @param {Date} start the time the fetch request started.
396  * @param {function(boolean)} cb Called with the result of the app id check.
397  * @param {number} rc The HTTP response code for the app id fetch.
398  * @param {!Array.<string>} allowedOrigins The origins allowed for this app id.
399  * @private
400  */
401 Enroller.prototype.fetchedAllowedOriginsForAppId_ =
402     function(appId, start, cb, rc, allowedOrigins) {
403   var end = new Date();
404   this.fetchedAppIds_++;
405   logFetchAppIdResult(appId, end - start, allowedOrigins, this.logMsgUrl_);
406   if (rc != 200 && !(rc >= 400 && rc < 500)) {
407     if (this.timer_.expired()) {
408       // Act as though the helper timed out.
409       this.helperError_(DeviceStatusCodes.TIMEOUT_STATUS, false);
410     } else {
411       start = new Date();
412       fetchAllowedOriginsForAppId(appId, this.allowHttp_,
413           this.fetchedAllowedOriginsForAppId_.bind(this, appId, start, cb));
414     }
415     return;
416   }
417   if (!isValidAppIdForOrigin(appId, this.origin_, allowedOrigins)) {
418     logInvalidOriginForAppId(this.origin_, appId, this.logMsgUrl_);
419     this.anyInvalidAppIds_ = true;
420     if (!this.appIdFailureReported_) {
421       // Only the failure case can happen more than once, so only report
422       // it the first time.
423       this.appIdFailureReported_ = true;
424       cb(false);
425     }
426   }
427   if (this.fetchedAppIds_ == this.distinctAppIds_.length &&
428       !this.anyInvalidAppIds_) {
429     // Last app id was fetched, and they were all valid: we're done.
430     cb(true);
431   }
432 };
433
434 /** Closes this enroller. */
435 Enroller.prototype.close = function() {
436   if (this.helper_) this.helper_.close();
437 };
438
439 /**
440  * Notifies the caller with the error code.
441  * @param {number} code Error code
442  * @private
443  */
444 Enroller.prototype.notifyError_ = function(code) {
445   if (this.done_)
446     return;
447   this.close();
448   this.done_ = true;
449   this.errorCb_(code);
450 };
451
452 /**
453  * Notifies the caller of success with the provided response data.
454  * @param {string} u2fVersion Protocol version
455  * @param {string} info Response data
456  * @param {string|undefined} opt_browserData Browser data used
457  * @private
458  */
459 Enroller.prototype.notifySuccess_ =
460     function(u2fVersion, info, opt_browserData) {
461   if (this.done_)
462     return;
463   this.close();
464   this.done_ = true;
465   this.successCb_(u2fVersion, info, opt_browserData);
466 };
467
468 /**
469  * Notifies the caller of progress with the error code.
470  * @param {number} code Status code
471  * @private
472  */
473 Enroller.prototype.notifyProgress_ = function(code) {
474   if (this.done_)
475     return;
476   if (code != this.lastProgressUpdate_) {
477     this.lastProgressUpdate_ = code;
478     // If there is no progress callback, treat it like an error and clean up.
479     if (this.progressCb_) {
480       this.progressCb_(code);
481     } else {
482       this.notifyError_(code);
483     }
484   }
485 };
486
487 /**
488  * Maps an enroll helper's error code namespace to the page's error code
489  * namespace.
490  * @param {number} code Error code from DeviceStatusCodes namespace.
491  * @param {boolean} anyGnubbies Whether any gnubbies were found.
492  * @return {number} A GnubbyCodeTypes error code.
493  * @private
494  */
495 Enroller.mapError_ = function(code, anyGnubbies) {
496   var reportedError = GnubbyCodeTypes.UNKNOWN_ERROR;
497   switch (code) {
498     case DeviceStatusCodes.WRONG_DATA_STATUS:
499       reportedError = anyGnubbies ? GnubbyCodeTypes.ALREADY_ENROLLED :
500           GnubbyCodeTypes.NO_GNUBBIES;
501       break;
502
503     case DeviceStatusCodes.WAIT_TOUCH_STATUS:
504       reportedError = GnubbyCodeTypes.WAIT_TOUCH;
505       break;
506
507     case DeviceStatusCodes.BUSY_STATUS:
508       reportedError = GnubbyCodeTypes.BUSY;
509       break;
510   }
511   return reportedError;
512 };
513
514 /**
515  * Called by the helper upon error.
516  * @param {number} code Error code
517  * @param {boolean} anyGnubbies If any gnubbies were found
518  * @private
519  */
520 Enroller.prototype.helperError_ = function(code, anyGnubbies) {
521   var reportedError = Enroller.mapError_(code, anyGnubbies);
522   console.log(UTIL_fmt('helper reported ' + code.toString(16) +
523       ', returning ' + reportedError));
524   this.notifyError_(reportedError);
525 };
526
527 /**
528  * Called by helper upon success.
529  * @param {string} u2fVersion gnubby version.
530  * @param {string} info enroll data.
531  * @private
532  */
533 Enroller.prototype.helperSuccess_ = function(u2fVersion, info) {
534   console.log(UTIL_fmt('Gnubby enrollment succeeded!!!!!'));
535
536   var browserData;
537   if (u2fVersion == 'U2F_V2') {
538     // For U2F_V2, the challenge sent to the gnubby is modified to be the hash
539     // of the browser data. Include the browser data.
540     browserData = this.browserData_[u2fVersion];
541   }
542
543   this.notifySuccess_(u2fVersion, info, browserData);
544 };
545
546 /**
547  * Called by helper to notify progress.
548  * @param {number} code Status code
549  * @param {boolean} anyGnubbies If any gnubbies were found
550  * @private
551  */
552 Enroller.prototype.helperProgress_ = function(code, anyGnubbies) {
553   var reportedError = Enroller.mapError_(code, anyGnubbies);
554   console.log(UTIL_fmt('helper notified ' + code.toString(16) +
555       ', returning ' + reportedError));
556   this.notifyProgress_(reportedError);
557 };