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 */ 19 function 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 */ 65 function 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 */ 120 function 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 */ 154 function 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 */ 186 var 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 */ 196 function 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 */ 233 function 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 */ 255 function 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 */ 283 function 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 */ 313 function 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 */ 348 Enroller.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 */ 357 Enroller.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 */ 414 Enroller.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 */ 446 Enroller.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 */ 493 Enroller.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 */ 509 Enroller.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. */ 522 Enroller.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 */ 537 Enroller.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 */ 552 Enroller.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 */ 566 Enroller.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