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.
6 * @fileoverview Handles web page requests for gnubby enrollment.
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
24 function handleEnrollRequest(factory, sender, request, enforceAppIdValid,
25 sendResponse, toleratesMultipleResponses) {
26 var sentResponse = false;
27 function sendResponseOnce(r) {
35 // If the page has gone away or the connection has otherwise gone,
36 // sendResponse fails.
39 console.warn('sendResponse failed: ' + exception);
42 console.warn(UTIL_fmt('Tried to reply more than once! Juan, FIX ME'));
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'];
52 sendResponseOnce(response);
55 var origin = getOriginFromUrl(/** @type {string} */ (sender.url));
57 sendErrorResponse(GnubbyCodeTypes.BAD_REQUEST);
61 if (!isValidEnrollRequest(request)) {
62 sendErrorResponse(GnubbyCodeTypes.BAD_REQUEST);
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;
75 function findChallengeOfVersion(enrollChallenges, version) {
76 for (var i = 0; i < enrollChallenges.length; i++) {
77 if (enrollChallenges[i]['version'] == version) {
78 return enrollChallenges[i];
84 function sendSuccessResponse(u2fVersion, info, browserData) {
85 var enrollChallenge = findChallengeOfVersion(enrollChallenges, u2fVersion);
86 if (!enrollChallenge) {
87 sendErrorResponse(GnubbyCodeTypes.UNKNOWN_ERROR);
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];
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;
101 var response = formatWebPageResponse(
102 GnubbyMsgTypes.ENROLL_WEB_REPLY, GnubbyCodeTypes.OK, enrollUpdateData);
103 sendResponseOnce(response);
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'];
115 sendResponse(response);
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);
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.
131 function isValidEnrollRequest(request) {
132 if (!request.hasOwnProperty('enrollChallenges'))
134 var enrollChallenges = request['enrollChallenges'];
135 if (!enrollChallenges.length)
137 var seenVersions = {};
138 for (var i = 0; i < enrollChallenges.length; i++) {
139 var enrollChallenge = enrollChallenges[i];
140 var version = enrollChallenge['version'];
142 // Version is implicitly V1 if not specified.
145 if (version != 'U2F_V1' && version != 'U2F_V2') {
148 if (seenVersions[version]) {
149 // Each version can appear at most once.
152 seenVersions[version] = version;
153 if (!enrollChallenge['appId']) {
156 if (!enrollChallenge['challenge']) {
157 // The challenge is required.
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))
169 * Creates a new object to track enrolling with a gnubby.
170 * @param {!EnrollHelperFactory} helperFactory factory to create an enroll
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
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.
186 function Enroller(helperFactory, timer, origin, errorCb, successCb,
187 opt_progressCb, opt_tlsChannelId, opt_logMsgUrl) {
188 /** @private {Countdown} */
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;
203 /** @private {boolean} */
205 /** @private {number|undefined} */
206 this.lastProgressUpdate_ = undefined;
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
216 /** @private {boolean} */
217 this.allowHttp_ = this.origin_ ? this.origin_.indexOf('http://') == 0 : false;
219 /** @private {EnrollHelper} */
220 this.helper_ = helperFactory.createHelper(timer,
221 this.helperError_.bind(this), this.helperSuccess_.bind(this),
222 this.helperProgress_.bind(this));
226 * Default timeout value in case the caller never provides a valid timeout.
228 Enroller.DEFAULT_TIMEOUT_MILLIS = 30 * 1000;
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
237 Enroller.prototype.doEnroll =
238 function(enrollChallenges, signChallenges, enforceAppIdValid) {
239 this.setEnrollChallenges_(enrollChallenges);
240 this.setSignChallenges_(signChallenges);
242 if (!enforceAppIdValid) {
243 // If not enforcing app id validity, begin enrolling right away.
244 this.helper_.doEnroll(this.encodedEnrollChallenges_,
245 this.encodedSignChallenges_);
247 // Whether or not enforcing app id validity, begin fetching/checking the
249 var enrollAppIds = [];
250 for (var i = 0; i < enrollChallenges.length; i++) {
251 enrollAppIds.push(enrollChallenges[i]['appId']);
254 this.checkAppIds_(enrollAppIds, signChallenges, function(result) {
255 if (!enforceAppIdValid) {
256 // Nothing to do, move along.
260 self.helper_.doEnroll(self.encodedEnrollChallenges_,
261 self.encodedSignChallenges_);
263 self.notifyError_(GnubbyCodeTypes.BAD_APP_ID);
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.
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 = {};
280 if (enrollChallenge['version']) {
281 version = enrollChallenge['version'];
283 // Version is implicitly V1 if not specified.
286 encodedChallenge['version'] = version;
287 encodedChallenge['challenge'] = enrollChallenge['challenge'];
288 encodedChallenge['appIdHash'] =
289 B64_encode(sha256HashOfString(enrollChallenge['appId']));
290 encodedChallenges.push(encodedChallenge);
292 return encodedChallenges;
296 * Sets this enroller's enroll challenges.
297 * @param {Array.<Object>} enrollChallenges The enroll challenges.
300 Enroller.prototype.setEnrollChallenges_ = function(enrollChallenges) {
302 for (var i = 0; i < enrollChallenges.length; i++) {
303 var enrollChallenge = enrollChallenges[i];
304 var version = enrollChallenge.version;
306 // Version is implicitly V1 if not specified.
310 if (version == 'U2F_V2') {
311 var modifiedChallenge = {};
312 for (var k in enrollChallenge) {
313 modifiedChallenge[k] = enrollChallenge[k];
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);
328 challenges.push(enrollChallenge);
331 // Store the encoded challenges for use by the enroll helper.
332 this.encodedEnrollChallenges_ =
333 Enroller.encodeEnrollChallenges_(challenges);
337 * Sets this enroller's sign data.
338 * @param {Array=} signData the sign challenges to add.
341 Enroller.prototype.setSignChallenges_ = function(signData) {
342 this.encodedSignChallenges_ = [];
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'];
350 var challenge = makeChallenge(serverChallenge, appId, encodedKeyHandle,
351 incomingChallenge['version']);
353 this.encodedSignChallenges_.push(challenge);
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.
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.
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;
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_.)
399 var start = new Date();
400 fetchAllowedOriginsForAppId(appId, this.allowHttp_,
401 this.fetchedAllowedOriginsForAppId_.bind(this, appId, start, cb));
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.
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);
426 fetchAllowedOriginsForAppId(appId, this.allowHttp_,
427 this.fetchedAllowedOriginsForAppId_.bind(this, appId, start, cb));
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;
441 if (this.fetchedAppIds_ == this.distinctAppIds_.length &&
442 !this.anyInvalidAppIds_) {
443 // Last app id was fetched, and they were all valid: we're done.
448 /** Closes this enroller. */
449 Enroller.prototype.close = function() {
450 if (this.helper_) this.helper_.close();
454 * Notifies the caller with the error code.
455 * @param {number} code Error code
458 Enroller.prototype.notifyError_ = function(code) {
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
473 Enroller.prototype.notifySuccess_ =
474 function(u2fVersion, info, opt_browserData) {
479 this.successCb_(u2fVersion, info, opt_browserData);
483 * Notifies the caller of progress with the error code.
484 * @param {number} code Status code
487 Enroller.prototype.notifyProgress_ = function(code) {
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);
496 this.notifyError_(code);
502 * Maps an enroll helper's error code namespace to the page's error code
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.
509 Enroller.mapError_ = function(code, anyGnubbies) {
510 var reportedError = GnubbyCodeTypes.UNKNOWN_ERROR;
512 case DeviceStatusCodes.WRONG_DATA_STATUS:
513 reportedError = anyGnubbies ? GnubbyCodeTypes.ALREADY_ENROLLED :
514 GnubbyCodeTypes.NO_GNUBBIES;
517 case DeviceStatusCodes.WAIT_TOUCH_STATUS:
518 reportedError = GnubbyCodeTypes.WAIT_TOUCH;
521 case DeviceStatusCodes.BUSY_STATUS:
522 reportedError = GnubbyCodeTypes.BUSY;
525 return reportedError;
529 * Called by the helper upon error.
530 * @param {number} code Error code
531 * @param {boolean} anyGnubbies If any gnubbies were found
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);
542 * Called by helper upon success.
543 * @param {string} u2fVersion gnubby version.
544 * @param {string} info enroll data.
547 Enroller.prototype.helperSuccess_ = function(u2fVersion, info) {
548 console.log(UTIL_fmt('Gnubby enrollment succeeded!!!!!'));
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];
557 this.notifySuccess_(u2fVersion, info, browserData);
561 * Called by helper to notify progress.
562 * @param {number} code Status code
563 * @param {boolean} anyGnubbies If any gnubbies were found
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);