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 */
22function 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 */
129function 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 */
184function 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 */
226Enroller.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 */
234Enroller.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 */
260Enroller.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 */
286Enroller.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 */
327Enroller.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 */
353Enroller.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 */
401Enroller.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. */
435Enroller.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 */
444Enroller.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 */
459Enroller.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 */
473Enroller.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 */
495Enroller.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 */
520Enroller.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 */
533Enroller.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 */
552Enroller.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};
558