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