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