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 sign requests. 7 */ 8 9 'use strict'; 10 11 var signRequestQueue = new OriginKeyedRequestQueue(); 12 13 /** 14 * Handles a sign request. 15 * @param {!SignHelperFactory} factory Factory to create a sign helper. 16 * @param {MessageSender} sender The sender of the message. 17 * @param {Object} request The web page's sign request. 18 * @param {Function} sendResponse Called back with the result of the sign. 19 * @param {boolean} toleratesMultipleResponses Whether the sendResponse 20 * callback can be called more than once, e.g. for progress updates. 21 * @return {Closeable} Request handler that should be closed when the browser 22 * message channel is closed. 23 */ 24 function handleSignRequest(factory, sender, request, sendResponse, 25 toleratesMultipleResponses) { 26 var sentResponse = false; 27 function sendResponseOnce(r) { 28 if (queuedSignRequest) { 29 queuedSignRequest.close(); 30 queuedSignRequest = null; 31 } 32 if (!sentResponse) { 33 sentResponse = true; 34 try { 35 // If the page has gone away or the connection has otherwise gone, 36 // sendResponse fails. 37 sendResponse(r); 38 } catch (exception) { 39 console.warn('sendResponse failed: ' + exception); 40 } 41 } else { 42 console.warn(UTIL_fmt('Tried to reply more than once! Juan, FIX ME')); 43 } 44 } 45 46 function sendErrorResponse(code) { 47 var response = formatWebPageResponse(GnubbyMsgTypes.SIGN_WEB_REPLY, code); 48 sendResponseOnce(response); 49 } 50 51 function sendSuccessResponse(challenge, info, browserData) { 52 var responseData = {}; 53 for (var k in challenge) { 54 responseData[k] = challenge[k]; 55 } 56 responseData['browserData'] = B64_encode(UTIL_StringToBytes(browserData)); 57 responseData['signatureData'] = info; 58 var response = formatWebPageResponse(GnubbyMsgTypes.SIGN_WEB_REPLY, 59 GnubbyCodeTypes.OK, responseData); 60 sendResponseOnce(response); 61 } 62 63 var origin = getOriginFromUrl(/** @type {string} */ (sender.url)); 64 if (!origin) { 65 sendErrorResponse(GnubbyCodeTypes.BAD_REQUEST); 66 return null; 67 } 68 // More closure type inference fail. 69 var nonNullOrigin = /** @type {string} */ (origin); 70 71 if (!isValidSignRequest(request)) { 72 sendErrorResponse(GnubbyCodeTypes.BAD_REQUEST); 73 return null; 74 } 75 76 var signData = request['signData']; 77 // A valid sign data has at least one challenge, so get the first appId from 78 // the first challenge. 79 var firstAppId = signData[0]['appId']; 80 var timeoutMillis = Signer.DEFAULT_TIMEOUT_MILLIS; 81 if (request['timeout']) { 82 // Request timeout is in seconds. 83 timeoutMillis = request['timeout'] * 1000; 84 } 85 var timer = new CountdownTimer(timeoutMillis); 86 var logMsgUrl = request['logMsgUrl']; 87 88 // Queue sign requests from the same origin, to protect against simultaneous 89 // sign-out on many tabs resulting in repeated sign-in requests. 90 var queuedSignRequest = new QueuedSignRequest(signData, factory, timer, 91 nonNullOrigin, sendErrorResponse, sendSuccessResponse, 92 sender.tlsChannelId, logMsgUrl); 93 var requestToken = signRequestQueue.queueRequest(firstAppId, nonNullOrigin, 94 queuedSignRequest.begin.bind(queuedSignRequest), timer); 95 queuedSignRequest.setToken(requestToken); 96 return queuedSignRequest; 97 } 98 99 /** 100 * Returns whether the request appears to be a valid sign request. 101 * @param {Object} request the request. 102 * @return {boolean} whether the request appears valid. 103 */ 104 function isValidSignRequest(request) { 105 if (!request.hasOwnProperty('signData')) 106 return false; 107 var signData = request['signData']; 108 // If a sign request contains an empty array of challenges, it could never 109 // be fulfilled. Fail. 110 if (!signData.length) 111 return false; 112 return isValidSignData(signData); 113 } 114 115 /** 116 * Adapter class representing a queued sign request. 117 * @param {!SignData} signData Signature data 118 * @param {!SignHelperFactory} factory Factory for SignHelper instances 119 * @param {Countdown} timer Timeout timer 120 * @param {string} origin Signature origin 121 * @param {function(number)} errorCb Error callback 122 * @param {function(SignChallenge, string, string)} successCb Success callback 123 * @param {string|undefined} opt_tlsChannelId TLS Channel Id 124 * @param {string|undefined} opt_logMsgUrl Url to post log messages to 125 * @constructor 126 * @implements {Closeable} 127 */ 128 function QueuedSignRequest(signData, factory, timer, origin, errorCb, successCb, 129 opt_tlsChannelId, opt_logMsgUrl) { 130 /** @private {!SignData} */ 131 this.signData_ = signData; 132 /** @private {!SignHelperFactory} */ 133 this.factory_ = factory; 134 /** @private {Countdown} */ 135 this.timer_ = timer; 136 /** @private {string} */ 137 this.origin_ = origin; 138 /** @private {function(number)} */ 139 this.errorCb_ = errorCb; 140 /** @private {function(SignChallenge, string, string)} */ 141 this.successCb_ = successCb; 142 /** @private {string|undefined} */ 143 this.tlsChannelId_ = opt_tlsChannelId; 144 /** @private {string|undefined} */ 145 this.logMsgUrl_ = opt_logMsgUrl; 146 /** @private {boolean} */ 147 this.begun_ = false; 148 /** @private {boolean} */ 149 this.closed_ = false; 150 } 151 152 /** Closes this sign request. */ 153 QueuedSignRequest.prototype.close = function() { 154 if (this.closed_) return; 155 if (this.begun_ && this.signer_) { 156 this.signer_.close(); 157 } 158 if (this.token_) { 159 this.token_.complete(); 160 } 161 this.closed_ = true; 162 }; 163 164 /** 165 * @param {QueuedRequestToken} token Token for this sign request. 166 */ 167 QueuedSignRequest.prototype.setToken = function(token) { 168 /** @private {QueuedRequestToken} */ 169 this.token_ = token; 170 }; 171 172 /** 173 * Called when this sign request may begin work. 174 * @param {QueuedRequestToken} token Token for this sign request. 175 */ 176 QueuedSignRequest.prototype.begin = function(token) { 177 this.begun_ = true; 178 this.setToken(token); 179 this.signer_ = new Signer(this.factory_, this.timer_, this.origin_, 180 this.signerFailed_.bind(this), this.signerSucceeded_.bind(this), 181 this.tlsChannelId_, this.logMsgUrl_); 182 if (!this.signer_.setChallenges(this.signData_)) { 183 token.complete(); 184 this.errorCb_(GnubbyCodeTypes.BAD_REQUEST); 185 } 186 }; 187 188 /** 189 * Called when this request's signer fails. 190 * @param {number} code The failure code reported by the signer. 191 * @private 192 */ 193 QueuedSignRequest.prototype.signerFailed_ = function(code) { 194 this.token_.complete(); 195 this.errorCb_(code); 196 }; 197 198 /** 199 * Called when this request's signer succeeds. 200 * @param {SignChallenge} challenge The challenge that was signed. 201 * @param {string} info The sign result. 202 * @param {string} browserData Browser data JSON 203 * @private 204 */ 205 QueuedSignRequest.prototype.signerSucceeded_ = 206 function(challenge, info, browserData) { 207 this.token_.complete(); 208 this.successCb_(challenge, info, browserData); 209 }; 210 211 /** 212 * Creates an object to track signing with a gnubby. 213 * @param {!SignHelperFactory} helperFactory Factory to create a sign helper. 214 * @param {Countdown} timer Timer for sign request. 215 * @param {string} origin The origin making the request. 216 * @param {function(number)} errorCb Called when the sign operation fails. 217 * @param {function(SignChallenge, string, string)} successCb Called when the 218 * sign operation succeeds. 219 * @param {string=} opt_tlsChannelId the TLS channel ID, if any, of the origin 220 * making the request. 221 * @param {string=} opt_logMsgUrl The url to post log messages to. 222 * @constructor 223 */ 224 function Signer(helperFactory, timer, origin, errorCb, successCb, 225 opt_tlsChannelId, opt_logMsgUrl) { 226 /** @private {Countdown} */ 227 this.timer_ = timer; 228 /** @private {string} */ 229 this.origin_ = origin; 230 /** @private {function(number)} */ 231 this.errorCb_ = errorCb; 232 /** @private {function(SignChallenge, string, string)} */ 233 this.successCb_ = successCb; 234 /** @private {string|undefined} */ 235 this.tlsChannelId_ = opt_tlsChannelId; 236 /** @private {string|undefined} */ 237 this.logMsgUrl_ = opt_logMsgUrl; 238 239 /** @private {boolean} */ 240 this.challengesSet_ = false; 241 /** @private {Array.<SignHelperChallenge>} */ 242 this.pendingChallenges_ = []; 243 /** @private {boolean} */ 244 this.done_ = false; 245 246 /** @private {Object.<string, string>} */ 247 this.browserData_ = {}; 248 /** @private {Object.<string, SignChallenge>} */ 249 this.serverChallenges_ = {}; 250 // Allow http appIds for http origins. (Broken, but the caller deserves 251 // what they get.) 252 /** @private {boolean} */ 253 this.allowHttp_ = this.origin_ ? this.origin_.indexOf('http://') == 0 : false; 254 255 // Protect against helper failure with a watchdog. 256 this.createWatchdog_(timer); 257 /** @private {SignHelper} */ 258 this.helper_ = helperFactory.createHelper( 259 timer, this.helperError_.bind(this), this.helperSuccess_.bind(this), 260 this.logMsgUrl_); 261 } 262 263 /** 264 * Creates a timer with an expiry greater than the expiration time of the given 265 * timer. 266 * @param {Countdown} timer Timeout timer 267 * @private 268 */ 269 Signer.prototype.createWatchdog_ = function(timer) { 270 var millis = timer.millisecondsUntilExpired(); 271 millis += CountdownTimer.TIMER_INTERVAL_MILLIS; 272 /** @private {Countdown|undefined} */ 273 this.watchdogTimer_ = new CountdownTimer(millis, this.timeout_.bind(this)); 274 }; 275 276 /** 277 * Default timeout value in case the caller never provides a valid timeout. 278 */ 279 Signer.DEFAULT_TIMEOUT_MILLIS = 30 * 1000; 280 281 /** 282 * Sets the challenges to be signed. 283 * @param {SignData} signData The challenges to set. 284 * @return {boolean} Whether the challenges could be set. 285 */ 286 Signer.prototype.setChallenges = function(signData) { 287 if (this.challengesSet_ || this.done_) 288 return false; 289 /** @private {SignData} */ 290 this.signData_ = signData; 291 /** @private {boolean} */ 292 this.challengesSet_ = true; 293 294 this.checkAppIds_(); 295 return true; 296 }; 297 298 /** 299 * Adds new challenges to the challenges being signed. 300 * @param {SignData} signData Challenges to add. 301 * @param {boolean} finalChallenges Whether these are the final challenges. 302 * @return {boolean} Whether the challenge could be added. 303 */ 304 Signer.prototype.addChallenges = function(signData, finalChallenges) { 305 var newChallenges = this.encodeSignChallenges_(signData); 306 for (var i = 0; i < newChallenges.length; i++) { 307 this.pendingChallenges_.push(newChallenges[i]); 308 } 309 if (!finalChallenges) { 310 return true; 311 } 312 return this.helper_.doSign(this.pendingChallenges_); 313 }; 314 315 /** 316 * Creates challenges for helper from challenges. 317 * @param {Array.<SignChallenge>} challenges Challenges to add. 318 * @return {Array.<SignHelperChallenge>} Encoded challenges 319 * @private 320 */ 321 Signer.prototype.encodeSignChallenges_ = function(challenges) { 322 var newChallenges = []; 323 for (var i = 0; i < challenges.length; i++) { 324 var incomingChallenge = challenges[i]; 325 var serverChallenge = incomingChallenge['challenge']; 326 var appId = incomingChallenge['appId']; 327 var encodedKeyHandle = incomingChallenge['keyHandle']; 328 var version = incomingChallenge['version']; 329 330 var browserData = 331 makeSignBrowserData(serverChallenge, this.origin_, this.tlsChannelId_); 332 var encodedChallenge = makeChallenge(browserData, appId, encodedKeyHandle, 333 version); 334 335 var key = encodedKeyHandle + encodedChallenge['challengeHash']; 336 this.browserData_[key] = browserData; 337 this.serverChallenges_[key] = incomingChallenge; 338 339 newChallenges.push(encodedChallenge); 340 } 341 return newChallenges; 342 }; 343 344 /** 345 * Checks the app ids of incoming requests, and, when this signer is enforcing 346 * that app ids are valid, adds successful challenges to those being signed. 347 * @private 348 */ 349 Signer.prototype.checkAppIds_ = function() { 350 // Check the incoming challenges' app ids. 351 /** @private {Array.<[string, Array.<Request>]>} */ 352 this.orderedRequests_ = requestsByAppId(this.signData_); 353 if (!this.orderedRequests_.length) { 354 // Safety check: if the challenges are somehow empty, the helper will never 355 // be fed any data, so the request could never be satisfied. You lose. 356 this.notifyError_(GnubbyCodeTypes.BAD_REQUEST); 357 return; 358 } 359 /** @private {number} */ 360 this.fetchedAppIds_ = 0; 361 /** @private {number} */ 362 this.validAppIds_ = 0; 363 for (var i = 0, appIdRequestsPair; i < this.orderedRequests_.length; i++) { 364 var appIdRequestsPair = this.orderedRequests_[i]; 365 var appId = appIdRequestsPair[0]; 366 var requests = appIdRequestsPair[1]; 367 if (appId == this.origin_) { 368 // Trivially allowed. 369 this.fetchedAppIds_++; 370 this.validAppIds_++; 371 this.addChallenges(requests, 372 this.fetchedAppIds_ == this.orderedRequests_.length); 373 } else { 374 var start = new Date(); 375 fetchAllowedOriginsForAppId(appId, this.allowHttp_, 376 this.fetchedAllowedOriginsForAppId_.bind(this, appId, start, 377 requests)); 378 } 379 } 380 }; 381 382 /** 383 * Called with the result of an app id fetch. 384 * @param {string} appId the app id that was fetched. 385 * @param {Date} start the time the fetch request started. 386 * @param {Array.<SignChallenge>} challenges Challenges for this app id. 387 * @param {number} rc The HTTP response code for the app id fetch. 388 * @param {!Array.<string>} allowedOrigins The origins allowed for this app id. 389 * @private 390 */ 391 Signer.prototype.fetchedAllowedOriginsForAppId_ = function(appId, start, 392 challenges, rc, allowedOrigins) { 393 var end = new Date(); 394 logFetchAppIdResult(appId, end - start, allowedOrigins, this.logMsgUrl_); 395 if (rc != 200 && !(rc >= 400 && rc < 500)) { 396 if (this.timer_.expired()) { 397 // Act as though the helper timed out. 398 this.helperError_(DeviceStatusCodes.TIMEOUT_STATUS, false); 399 } else { 400 start = new Date(); 401 fetchAllowedOriginsForAppId(appId, this.allowHttp_, 402 this.fetchedAllowedOriginsForAppId_.bind(this, appId, start, 403 challenges)); 404 } 405 return; 406 } 407 this.fetchedAppIds_++; 408 var finalChallenges = (this.fetchedAppIds_ == this.orderedRequests_.length); 409 if (isValidAppIdForOrigin(appId, this.origin_, allowedOrigins)) { 410 this.validAppIds_++; 411 this.addChallenges(challenges, finalChallenges); 412 } else { 413 logInvalidOriginForAppId(this.origin_, appId, this.logMsgUrl_); 414 // If this is the final request, sign the valid challenges. 415 if (finalChallenges) { 416 if (!this.helper_.doSign(this.pendingChallenges_)) { 417 this.notifyError_(GnubbyCodeTypes.BAD_REQUEST); 418 return; 419 } 420 } 421 } 422 if (finalChallenges && !this.validAppIds_) { 423 // If all app ids are invalid, notify the caller, otherwise implicitly 424 // allow the helper to report whether any of the valid challenges succeeded. 425 this.notifyError_(GnubbyCodeTypes.BAD_APP_ID); 426 } 427 }; 428 429 /** 430 * Called when the timeout expires on this signer. 431 * @private 432 */ 433 Signer.prototype.timeout_ = function() { 434 this.watchdogTimer_ = undefined; 435 // The web page gets grumpy if it doesn't get WAIT_TOUCH within a reasonable 436 // time. 437 this.notifyError_(GnubbyCodeTypes.WAIT_TOUCH); 438 }; 439 440 /** Closes this signer. */ 441 Signer.prototype.close = function() { 442 if (this.helper_) this.helper_.close(); 443 }; 444 445 /** 446 * Notifies the caller of error with the given error code. 447 * @param {number} code Error code 448 * @private 449 */ 450 Signer.prototype.notifyError_ = function(code) { 451 if (this.done_) 452 return; 453 this.close(); 454 this.done_ = true; 455 this.errorCb_(code); 456 }; 457 458 /** 459 * Notifies the caller of success. 460 * @param {SignChallenge} challenge The challenge that was signed. 461 * @param {string} info The sign result. 462 * @param {string} browserData Browser data JSON 463 * @private 464 */ 465 Signer.prototype.notifySuccess_ = function(challenge, info, browserData) { 466 if (this.done_) 467 return; 468 this.close(); 469 this.done_ = true; 470 this.successCb_(challenge, info, browserData); 471 }; 472 473 /** 474 * Maps a sign helper's error code namespace to the page's error code namespace. 475 * @param {number} code Error code from DeviceStatusCodes namespace. 476 * @param {boolean} anyGnubbies Whether any gnubbies were found. 477 * @return {number} A GnubbyCodeTypes error code. 478 * @private 479 */ 480 Signer.mapError_ = function(code, anyGnubbies) { 481 var reportedError; 482 switch (code) { 483 case DeviceStatusCodes.WRONG_DATA_STATUS: 484 reportedError = anyGnubbies ? GnubbyCodeTypes.NONE_PLUGGED_ENROLLED : 485 GnubbyCodeTypes.NO_GNUBBIES; 486 break; 487 488 case DeviceStatusCodes.OK_STATUS: 489 // If the error callback is called with OK, it means the signature was 490 // empty, which we treat the same as... 491 case DeviceStatusCodes.WAIT_TOUCH_STATUS: 492 reportedError = GnubbyCodeTypes.WAIT_TOUCH; 493 break; 494 495 case DeviceStatusCodes.BUSY_STATUS: 496 reportedError = GnubbyCodeTypes.BUSY; 497 break; 498 499 default: 500 reportedError = GnubbyCodeTypes.UNKNOWN_ERROR; 501 break; 502 } 503 return reportedError; 504 }; 505 506 /** 507 * Called by the helper upon error. 508 * @param {number} code Error code 509 * @param {boolean} anyGnubbies If any gnubbies were found 510 * @private 511 */ 512 Signer.prototype.helperError_ = function(code, anyGnubbies) { 513 this.clearTimeout_(); 514 var reportedError = Signer.mapError_(code, anyGnubbies); 515 console.log(UTIL_fmt('helper reported ' + code.toString(16) + 516 ', returning ' + reportedError)); 517 this.notifyError_(reportedError); 518 }; 519 520 /** 521 * Called by helper upon success. 522 * @param {SignHelperChallenge} challenge The challenge that was signed. 523 * @param {string} info The sign result. 524 * @param {string=} opt_source The source, if any, if the signature. 525 * @private 526 */ 527 Signer.prototype.helperSuccess_ = function(challenge, info, opt_source) { 528 // Got a good reply, kill timer. 529 this.clearTimeout_(); 530 531 if (this.logMsgUrl_ && opt_source) { 532 var logMsg = 'signed&source=' + opt_source; 533 logMessage(logMsg, this.logMsgUrl_); 534 } 535 536 var key = challenge['keyHandle'] + challenge['challengeHash']; 537 var browserData = this.browserData_[key]; 538 // Notify with server-provided challenge, not the encoded one: the 539 // server-provided challenge contains additional fields it relies on. 540 var serverChallenge = this.serverChallenges_[key]; 541 this.notifySuccess_(serverChallenge, info, browserData); 542 }; 543 544 /** 545 * Clears the timeout for this signer. 546 * @private 547 */ 548 Signer.prototype.clearTimeout_ = function() { 549 if (this.watchdogTimer_) { 550 this.watchdogTimer_.clearTimeout(); 551 this.watchdogTimer_ = undefined; 552 } 553 }; 554