• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 a web enroll request.
13 * @param {MessageSender} sender The sender of the message.
14 * @param {Object} request The web page's enroll request.
15 * @param {Function} sendResponse Called back with the result of the enroll.
16 * @return {Closeable} A handler object to be closed when the browser channel
17 *     closes.
18 */
19function handleWebEnrollRequest(sender, request, sendResponse) {
20  var sentResponse = false;
21  var closeable = null;
22
23  function sendErrorResponse(error) {
24    var response = makeWebErrorResponse(request,
25        mapErrorCodeToGnubbyCodeType(error.errorCode, false /* forSign */));
26    sendResponseOnce(sentResponse, closeable, response, sendResponse);
27  }
28
29  function sendSuccessResponse(u2fVersion, info, browserData) {
30    var enrollChallenges = request['enrollChallenges'];
31    var enrollChallenge =
32        findEnrollChallengeOfVersion(enrollChallenges, u2fVersion);
33    if (!enrollChallenge) {
34      sendErrorResponse(ErrorCodes.OTHER_ERROR);
35      return;
36    }
37    var responseData =
38        makeEnrollResponseData(enrollChallenge, u2fVersion,
39            'enrollData', info, 'browserData', browserData);
40    var response = makeWebSuccessResponse(request, responseData);
41    sendResponseOnce(sentResponse, closeable, response, sendResponse);
42  }
43
44  var enroller =
45      validateEnrollRequest(
46          sender, request, 'enrollChallenges', 'signData',
47          sendErrorResponse, sendSuccessResponse);
48  if (enroller) {
49    var registerRequests = request['enrollChallenges'];
50    var signRequests = getSignRequestsFromEnrollRequest(request, 'signData');
51    closeable = /** @type {Closeable} */ (enroller);
52    enroller.doEnroll(registerRequests, signRequests, request['appId']);
53  }
54  return closeable;
55}
56
57/**
58 * Handles a U2F enroll request.
59 * @param {MessageSender} sender The sender of the message.
60 * @param {Object} request The web page's enroll request.
61 * @param {Function} sendResponse Called back with the result of the enroll.
62 * @return {Closeable} A handler object to be closed when the browser channel
63 *     closes.
64 */
65function handleU2fEnrollRequest(sender, request, sendResponse) {
66  var sentResponse = false;
67  var closeable = null;
68
69  function sendErrorResponse(error) {
70    var response = makeU2fErrorResponse(request, error.errorCode,
71        error.errorMessage);
72    sendResponseOnce(sentResponse, closeable, response, sendResponse);
73  }
74
75  function sendSuccessResponse(u2fVersion, info, browserData) {
76    var enrollChallenges = request['registerRequests'];
77    var enrollChallenge =
78        findEnrollChallengeOfVersion(enrollChallenges, u2fVersion);
79    if (!enrollChallenge) {
80      sendErrorResponse(ErrorCodes.OTHER_ERROR);
81      return;
82    }
83    var responseData =
84        makeEnrollResponseData(enrollChallenge, u2fVersion,
85            'registrationData', info, 'clientData', browserData);
86    var response = makeU2fSuccessResponse(request, responseData);
87    sendResponseOnce(sentResponse, closeable, response, sendResponse);
88  }
89
90  var enroller =
91      validateEnrollRequest(
92          sender, request, 'registerRequests', 'signRequests',
93          sendErrorResponse, sendSuccessResponse, 'registeredKeys');
94  if (enroller) {
95    var registerRequests = request['registerRequests'];
96    var signRequests = getSignRequestsFromEnrollRequest(request,
97        'signRequests', 'registeredKeys');
98    closeable = /** @type {Closeable} */ (enroller);
99    enroller.doEnroll(registerRequests, signRequests, request['appId']);
100  }
101  return closeable;
102}
103
104/**
105 * Validates an enroll request using the given parameters.
106 * @param {MessageSender} sender The sender of the message.
107 * @param {Object} request The web page's enroll request.
108 * @param {string} enrollChallengesName The name of the enroll challenges value
109 *     in the request.
110 * @param {string} signChallengesName The name of the sign challenges value in
111 *     the request.
112 * @param {function(U2fError)} errorCb Error callback.
113 * @param {function(string, string, (string|undefined))} successCb Success
114 *     callback.
115 * @param {string=} opt_registeredKeysName The name of the registered keys
116 *     value in the request.
117 * @return {Enroller} Enroller object representing the request, if the request
118 *     is valid, or null if the request is invalid.
119 */
120function validateEnrollRequest(sender, request,
121    enrollChallengesName, signChallengesName, errorCb, successCb,
122    opt_registeredKeysName) {
123  var origin = getOriginFromUrl(/** @type {string} */ (sender.url));
124  if (!origin) {
125    errorCb({errorCode: ErrorCodes.BAD_REQUEST});
126    return null;
127  }
128
129  if (!isValidEnrollRequest(request, enrollChallengesName,
130      signChallengesName, opt_registeredKeysName)) {
131    errorCb({errorCode: ErrorCodes.BAD_REQUEST});
132    return null;
133  }
134
135  var timer = createTimerForRequest(
136      FACTORY_REGISTRY.getCountdownFactory(), request);
137  var logMsgUrl = request['logMsgUrl'];
138  var enroller = new Enroller(timer, origin, errorCb, successCb,
139      sender.tlsChannelId, logMsgUrl);
140  return enroller;
141}
142
143/**
144 * Returns whether the request appears to be a valid enroll request.
145 * @param {Object} request The request.
146 * @param {string} enrollChallengesName The name of the enroll challenges value
147 *     in the request.
148 * @param {string} signChallengesName The name of the sign challenges value in
149 *     the request.
150 * @param {string=} opt_registeredKeysName The name of the registered keys
151 *     value in the request.
152 * @return {boolean} Whether the request appears valid.
153 */
154function isValidEnrollRequest(request, enrollChallengesName,
155    signChallengesName, opt_registeredKeysName) {
156  if (!request.hasOwnProperty(enrollChallengesName))
157    return false;
158  var enrollChallenges = request[enrollChallengesName];
159  if (!enrollChallenges.length)
160    return false;
161  var hasAppId = request.hasOwnProperty('appId');
162  if (!isValidEnrollChallengeArray(enrollChallenges, !hasAppId))
163    return false;
164  var signChallenges = request[signChallengesName];
165  // A missing sign challenge array is ok, in the case the user is not already
166  // enrolled.
167  if (signChallenges && !isValidSignChallengeArray(signChallenges, !hasAppId))
168    return false;
169  if (opt_registeredKeysName) {
170    var registeredKeys = request[opt_registeredKeysName];
171    if (registeredKeys &&
172        !isValidRegisteredKeyArray(registeredKeys, !hasAppId)) {
173      return false;
174    }
175  }
176  return true;
177}
178
179/**
180 * @typedef {{
181 *   version: (string|undefined),
182 *   challenge: string,
183 *   appId: string
184 * }}
185 */
186var EnrollChallenge;
187
188/**
189 * @param {Array.<EnrollChallenge>} enrollChallenges The enroll challenges to
190 *     validate.
191 * @param {boolean} appIdRequired Whether the appId property is required on
192 *     each challenge.
193 * @return {boolean} Whether the given array of challenges is a valid enroll
194 *     challenges array.
195 */
196function isValidEnrollChallengeArray(enrollChallenges, appIdRequired) {
197  var seenVersions = {};
198  for (var i = 0; i < enrollChallenges.length; i++) {
199    var enrollChallenge = enrollChallenges[i];
200    var version = enrollChallenge['version'];
201    if (!version) {
202      // Version is implicitly V1 if not specified.
203      version = 'U2F_V1';
204    }
205    if (version != 'U2F_V1' && version != 'U2F_V2') {
206      return false;
207    }
208    if (seenVersions[version]) {
209      // Each version can appear at most once.
210      return false;
211    }
212    seenVersions[version] = version;
213    if (appIdRequired && !enrollChallenge['appId']) {
214      return false;
215    }
216    if (!enrollChallenge['challenge']) {
217      // The challenge is required.
218      return false;
219    }
220  }
221  return true;
222}
223
224/**
225 * Finds the enroll challenge of the given version in the enroll challlenge
226 * array.
227 * @param {Array.<EnrollChallenge>} enrollChallenges The enroll challenges to
228 *     search.
229 * @param {string} version Version to search for.
230 * @return {?EnrollChallenge} The enroll challenge with the given versions, or
231 *     null if it isn't found.
232 */
233function findEnrollChallengeOfVersion(enrollChallenges, version) {
234  for (var i = 0; i < enrollChallenges.length; i++) {
235    if (enrollChallenges[i]['version'] == version) {
236      return enrollChallenges[i];
237    }
238  }
239  return null;
240}
241
242/**
243 * Makes a responseData object for the enroll request with the given parameters.
244 * @param {EnrollChallenge} enrollChallenge The enroll challenge used to
245 *     register.
246 * @param {string} u2fVersion Version of gnubby that enrolled.
247 * @param {string} enrollDataName The name of the enroll data key in the
248 *     responseData object.
249 * @param {string} enrollData The enroll data.
250 * @param {string} browserDataName The name of the browser data key in the
251 *     responseData object.
252 * @param {string=} browserData The browser data, if available.
253 * @return {Object} The responseData object.
254 */
255function makeEnrollResponseData(enrollChallenge, u2fVersion, enrollDataName,
256    enrollData, browserDataName, browserData) {
257  var responseData = {};
258  responseData[enrollDataName] = enrollData;
259  // Echo the used challenge back in the reply.
260  for (var k in enrollChallenge) {
261    responseData[k] = enrollChallenge[k];
262  }
263  if (u2fVersion == 'U2F_V2') {
264    // For U2F_V2, the challenge sent to the gnubby is modified to be the
265    // hash of the browser data. Include the browser data.
266    responseData[browserDataName] = browserData;
267  }
268  return responseData;
269}
270
271/**
272 * Gets the expanded sign challenges from an enroll request, potentially by
273 * modifying the request to contain a challenge value where one was omitted.
274 * (For enrolling, the server isn't interested in the value of a signature,
275 * only whether the presented key handle is already enrolled.)
276 * @param {Object} request The request.
277 * @param {string} signChallengesName The name of the sign challenges value in
278 *     the request.
279 * @param {string=} opt_registeredKeysName The name of the registered keys
280 *     value in the request.
281 * @return {Array.<SignChallenge>}
282 */
283function getSignRequestsFromEnrollRequest(request, signChallengesName,
284    opt_registeredKeysName) {
285  var signChallenges;
286  if (opt_registeredKeysName &&
287      request.hasOwnProperty(opt_registeredKeysName)) {
288    // Convert registered keys to sign challenges by adding a challenge value.
289    signChallenges = request[opt_registeredKeysName];
290    for (var i = 0; i < signChallenges.length; i++) {
291      // The actual value doesn't matter, as long as it's a string.
292      signChallenges[i]['challenge'] = '';
293    }
294  } else {
295    signChallenges = request[signChallengesName];
296  }
297  return signChallenges;
298}
299
300/**
301 * Creates a new object to track enrolling with a gnubby.
302 * @param {!Countdown} timer Timer for enroll request.
303 * @param {string} origin The origin making the request.
304 * @param {function(U2fError)} errorCb Called upon enroll failure.
305 * @param {function(string, string, (string|undefined))} successCb Called upon
306 *     enroll success with the version of the succeeding gnubby, the enroll
307 *     data, and optionally the browser data associated with the enrollment.
308 * @param {string=} opt_tlsChannelId the TLS channel ID, if any, of the origin
309 *     making the request.
310 * @param {string=} opt_logMsgUrl The url to post log messages to.
311 * @constructor
312 */
313function Enroller(timer, origin, errorCb, successCb, opt_tlsChannelId,
314    opt_logMsgUrl) {
315  /** @private {Countdown} */
316  this.timer_ = timer;
317  /** @private {string} */
318  this.origin_ = origin;
319  /** @private {function(U2fError)} */
320  this.errorCb_ = errorCb;
321  /** @private {function(string, string, (string|undefined))} */
322  this.successCb_ = successCb;
323  /** @private {string|undefined} */
324  this.tlsChannelId_ = opt_tlsChannelId;
325  /** @private {string|undefined} */
326  this.logMsgUrl_ = opt_logMsgUrl;
327
328  /** @private {boolean} */
329  this.done_ = false;
330
331  /** @private {Object.<string, string>} */
332  this.browserData_ = {};
333  /** @private {Array.<EnrollHelperChallenge>} */
334  this.encodedEnrollChallenges_ = [];
335  /** @private {Array.<SignHelperChallenge>} */
336  this.encodedSignChallenges_ = [];
337  // Allow http appIds for http origins. (Broken, but the caller deserves
338  // what they get.)
339  /** @private {boolean} */
340  this.allowHttp_ = this.origin_ ? this.origin_.indexOf('http://') == 0 : false;
341  /** @private {Closeable} */
342  this.handler_ = null;
343}
344
345/**
346 * Default timeout value in case the caller never provides a valid timeout.
347 */
348Enroller.DEFAULT_TIMEOUT_MILLIS = 30 * 1000;
349
350/**
351 * Performs an enroll request with the given enroll and sign challenges.
352 * @param {Array.<EnrollChallenge>} enrollChallenges A set of enroll challenges.
353 * @param {Array.<SignChallenge>} signChallenges A set of sign challenges for
354 *     existing enrollments for this user and appId.
355 * @param {string=} opt_appId The app id for the entire request.
356 */
357Enroller.prototype.doEnroll = function(enrollChallenges, signChallenges,
358    opt_appId) {
359  var encodedEnrollChallenges =
360      this.encodeEnrollChallenges_(enrollChallenges, opt_appId);
361  var encodedSignChallenges = encodeSignChallenges(signChallenges, opt_appId);
362  var request = {
363    type: 'enroll_helper_request',
364    enrollChallenges: encodedEnrollChallenges,
365    signData: encodedSignChallenges,
366    logMsgUrl: this.logMsgUrl_
367  };
368  if (!this.timer_.expired()) {
369    request.timeout = this.timer_.millisecondsUntilExpired() / 1000.0;
370    request.timeoutSeconds = this.timer_.millisecondsUntilExpired() / 1000.0;
371  }
372
373  // Begin fetching/checking the app ids.
374  var enrollAppIds = [];
375  if (opt_appId) {
376    enrollAppIds.push(opt_appId);
377  }
378  for (var i = 0; i < enrollChallenges.length; i++) {
379    if (enrollChallenges[i].hasOwnProperty('appId')) {
380      enrollAppIds.push(enrollChallenges[i]['appId']);
381    }
382  }
383  // Sanity check
384  if (!enrollAppIds.length) {
385    console.warn(UTIL_fmt('empty enroll app ids?'));
386    this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
387    return;
388  }
389  var self = this;
390  this.checkAppIds_(enrollAppIds, signChallenges, function(result) {
391    if (result) {
392      self.handler_ = FACTORY_REGISTRY.getRequestHelper().getHandler(request);
393      if (self.handler_) {
394        var helperComplete =
395            /** @type {function(HelperReply)} */
396            (self.helperComplete_.bind(self));
397        self.handler_.run(helperComplete);
398      } else {
399        self.notifyError_({errorCode: ErrorCodes.OTHER_ERROR});
400      }
401    } else {
402      self.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
403    }
404  });
405};
406
407/**
408 * Encodes the enroll challenge as an enroll helper challenge.
409 * @param {EnrollChallenge} enrollChallenge The enroll challenge to encode.
410 * @param {string=} opt_appId The app id for the entire request.
411 * @return {EnrollHelperChallenge} The encoded challenge.
412 * @private
413 */
414Enroller.encodeEnrollChallenge_ = function(enrollChallenge, opt_appId) {
415  var encodedChallenge = {};
416  var version;
417  if (enrollChallenge['version']) {
418    version = enrollChallenge['version'];
419  } else {
420    // Version is implicitly V1 if not specified.
421    version = 'U2F_V1';
422  }
423  encodedChallenge['version'] = version;
424  encodedChallenge['challengeHash'] = enrollChallenge['challenge'];
425  var appId;
426  if (enrollChallenge['appId']) {
427    appId = enrollChallenge['appId'];
428  } else {
429    appId = opt_appId;
430  }
431  if (!appId) {
432    // Sanity check. (Other code should fail if it's not set.)
433    console.warn(UTIL_fmt('No appId?'));
434  }
435  encodedChallenge['appIdHash'] = B64_encode(sha256HashOfString(appId));
436  return /** @type {EnrollHelperChallenge} */ (encodedChallenge);
437};
438
439/**
440 * Encodes the given enroll challenges using this enroller's state.
441 * @param {Array.<EnrollChallenge>} enrollChallenges The enroll challenges.
442 * @param {string=} opt_appId The app id for the entire request.
443 * @return {!Array.<EnrollHelperChallenge>} The encoded enroll challenges.
444 * @private
445 */
446Enroller.prototype.encodeEnrollChallenges_ = function(enrollChallenges,
447    opt_appId) {
448  var challenges = [];
449  for (var i = 0; i < enrollChallenges.length; i++) {
450    var enrollChallenge = enrollChallenges[i];
451    var version = enrollChallenge.version;
452    if (!version) {
453      // Version is implicitly V1 if not specified.
454      version = 'U2F_V1';
455    }
456
457    if (version == 'U2F_V2') {
458      var modifiedChallenge = {};
459      for (var k in enrollChallenge) {
460        modifiedChallenge[k] = enrollChallenge[k];
461      }
462      // V2 enroll responses contain signatures over a browser data object,
463      // which we're constructing here. The browser data object contains, among
464      // other things, the server challenge.
465      var serverChallenge = enrollChallenge['challenge'];
466      var browserData = makeEnrollBrowserData(
467          serverChallenge, this.origin_, this.tlsChannelId_);
468      // Replace the challenge with the hash of the browser data.
469      modifiedChallenge['challenge'] =
470          B64_encode(sha256HashOfString(browserData));
471      this.browserData_[version] =
472          B64_encode(UTIL_StringToBytes(browserData));
473      challenges.push(Enroller.encodeEnrollChallenge_(
474          /** @type {EnrollChallenge} */ (modifiedChallenge), opt_appId));
475    } else {
476      challenges.push(
477          Enroller.encodeEnrollChallenge_(enrollChallenge, opt_appId));
478    }
479  }
480  return challenges;
481};
482
483/**
484 * Checks the app ids associated with this enroll request, and calls a callback
485 * with the result of the check.
486 * @param {!Array.<string>} enrollAppIds The app ids in the enroll challenge
487 *     portion of the enroll request.
488 * @param {Array.<SignChallenge>} signChallenges The sign challenges associated
489 *     with the request.
490 * @param {function(boolean)} cb Called with the result of the check.
491 * @private
492 */
493Enroller.prototype.checkAppIds_ = function(enrollAppIds, signChallenges, cb) {
494  var appIds =
495      UTIL_unionArrays(enrollAppIds, getDistinctAppIds(signChallenges));
496  FACTORY_REGISTRY.getOriginChecker().canClaimAppIds(this.origin_, appIds)
497      .then(this.originChecked_.bind(this, appIds, cb));
498};
499
500/**
501 * Called with the result of checking the origin. When the origin is allowed
502 * to claim the app ids, begins checking whether the app ids also list the
503 * origin.
504 * @param {!Array.<string>} appIds The app ids.
505 * @param {function(boolean)} cb Called with the result of the check.
506 * @param {boolean} result Whether the origin could claim the app ids.
507 * @private
508 */
509Enroller.prototype.originChecked_ = function(appIds, cb, result) {
510  if (!result) {
511    this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
512    return;
513  }
514  /** @private {!AppIdChecker} */
515  this.appIdChecker_ = new AppIdChecker(FACTORY_REGISTRY.getTextFetcher(),
516      this.timer_.clone(), this.origin_, appIds, this.allowHttp_,
517      this.logMsgUrl_);
518  this.appIdChecker_.doCheck().then(cb);
519};
520
521/** Closes this enroller. */
522Enroller.prototype.close = function() {
523  if (this.appIdChecker_) {
524    this.appIdChecker_.close();
525  }
526  if (this.handler_) {
527    this.handler_.close();
528    this.handler_ = null;
529  }
530};
531
532/**
533 * Notifies the caller with the error.
534 * @param {U2fError} error Error.
535 * @private
536 */
537Enroller.prototype.notifyError_ = function(error) {
538  if (this.done_)
539    return;
540  this.close();
541  this.done_ = true;
542  this.errorCb_(error);
543};
544
545/**
546 * Notifies the caller of success with the provided response data.
547 * @param {string} u2fVersion Protocol version
548 * @param {string} info Response data
549 * @param {string|undefined} opt_browserData Browser data used
550 * @private
551 */
552Enroller.prototype.notifySuccess_ =
553    function(u2fVersion, info, opt_browserData) {
554  if (this.done_)
555    return;
556  this.close();
557  this.done_ = true;
558  this.successCb_(u2fVersion, info, opt_browserData);
559};
560
561/**
562 * Called by the helper upon completion.
563 * @param {EnrollHelperReply} reply The result of the enroll request.
564 * @private
565 */
566Enroller.prototype.helperComplete_ = function(reply) {
567  if (reply.code) {
568    var reportedError = mapDeviceStatusCodeToU2fError(reply.code);
569    console.log(UTIL_fmt('helper reported ' + reply.code.toString(16) +
570        ', returning ' + reportedError.errorCode));
571    this.notifyError_(reportedError);
572  } else {
573    console.log(UTIL_fmt('Gnubby enrollment succeeded!!!!!'));
574    var browserData;
575
576    if (reply.version == 'U2F_V2') {
577      // For U2F_V2, the challenge sent to the gnubby is modified to be the hash
578      // of the browser data. Include the browser data.
579      browserData = this.browserData_[reply.version];
580    }
581
582    this.notifySuccess_(/** @type {string} */ (reply.version),
583                        /** @type {string} */ (reply.enrollData),
584                        browserData);
585  }
586};
587