// Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview Handles web page requests for gnubby sign requests. */ 'use strict'; var signRequestQueue = new OriginKeyedRequestQueue(); /** * Handles a sign request. * @param {!SignHelperFactory} factory Factory to create a sign helper. * @param {MessageSender} sender The sender of the message. * @param {Object} request The web page's sign request. * @param {Function} sendResponse Called back with the result of the sign. * @param {boolean} toleratesMultipleResponses Whether the sendResponse * callback can be called more than once, e.g. for progress updates. * @return {Closeable} Request handler that should be closed when the browser * message channel is closed. */ function handleSignRequest(factory, sender, request, sendResponse, toleratesMultipleResponses) { var sentResponse = false; function sendResponseOnce(r) { if (queuedSignRequest) { queuedSignRequest.close(); queuedSignRequest = null; } if (!sentResponse) { sentResponse = true; try { // If the page has gone away or the connection has otherwise gone, // sendResponse fails. sendResponse(r); } catch (exception) { console.warn('sendResponse failed: ' + exception); } } else { console.warn(UTIL_fmt('Tried to reply more than once! Juan, FIX ME')); } } function sendErrorResponse(code) { var response = formatWebPageResponse(GnubbyMsgTypes.SIGN_WEB_REPLY, code); sendResponseOnce(response); } function sendSuccessResponse(challenge, info, browserData) { var responseData = {}; for (var k in challenge) { responseData[k] = challenge[k]; } responseData['browserData'] = B64_encode(UTIL_StringToBytes(browserData)); responseData['signatureData'] = info; var response = formatWebPageResponse(GnubbyMsgTypes.SIGN_WEB_REPLY, GnubbyCodeTypes.OK, responseData); sendResponseOnce(response); } var origin = getOriginFromUrl(/** @type {string} */ (sender.url)); if (!origin) { sendErrorResponse(GnubbyCodeTypes.BAD_REQUEST); return null; } // More closure type inference fail. var nonNullOrigin = /** @type {string} */ (origin); if (!isValidSignRequest(request)) { sendErrorResponse(GnubbyCodeTypes.BAD_REQUEST); return null; } var signData = request['signData']; // A valid sign data has at least one challenge, so get the first appId from // the first challenge. var firstAppId = signData[0]['appId']; var timeoutMillis = Signer.DEFAULT_TIMEOUT_MILLIS; if (request['timeout']) { // Request timeout is in seconds. timeoutMillis = request['timeout'] * 1000; } var timer = new CountdownTimer(timeoutMillis); var logMsgUrl = request['logMsgUrl']; // Queue sign requests from the same origin, to protect against simultaneous // sign-out on many tabs resulting in repeated sign-in requests. var queuedSignRequest = new QueuedSignRequest(signData, factory, timer, nonNullOrigin, sendErrorResponse, sendSuccessResponse, sender.tlsChannelId, logMsgUrl); var requestToken = signRequestQueue.queueRequest(firstAppId, nonNullOrigin, queuedSignRequest.begin.bind(queuedSignRequest), timer); queuedSignRequest.setToken(requestToken); return queuedSignRequest; } /** * Returns whether the request appears to be a valid sign request. * @param {Object} request the request. * @return {boolean} whether the request appears valid. */ function isValidSignRequest(request) { if (!request.hasOwnProperty('signData')) return false; var signData = request['signData']; // If a sign request contains an empty array of challenges, it could never // be fulfilled. Fail. if (!signData.length) return false; return isValidSignData(signData); } /** * Adapter class representing a queued sign request. * @param {!SignData} signData Signature data * @param {!SignHelperFactory} factory Factory for SignHelper instances * @param {Countdown} timer Timeout timer * @param {string} origin Signature origin * @param {function(number)} errorCb Error callback * @param {function(SignChallenge, string, string)} successCb Success callback * @param {string|undefined} opt_tlsChannelId TLS Channel Id * @param {string|undefined} opt_logMsgUrl Url to post log messages to * @constructor * @implements {Closeable} */ function QueuedSignRequest(signData, factory, timer, origin, errorCb, successCb, opt_tlsChannelId, opt_logMsgUrl) { /** @private {!SignData} */ this.signData_ = signData; /** @private {!SignHelperFactory} */ this.factory_ = factory; /** @private {Countdown} */ this.timer_ = timer; /** @private {string} */ this.origin_ = origin; /** @private {function(number)} */ this.errorCb_ = errorCb; /** @private {function(SignChallenge, string, string)} */ this.successCb_ = successCb; /** @private {string|undefined} */ this.tlsChannelId_ = opt_tlsChannelId; /** @private {string|undefined} */ this.logMsgUrl_ = opt_logMsgUrl; /** @private {boolean} */ this.begun_ = false; /** @private {boolean} */ this.closed_ = false; } /** Closes this sign request. */ QueuedSignRequest.prototype.close = function() { if (this.closed_) return; if (this.begun_ && this.signer_) { this.signer_.close(); } if (this.token_) { this.token_.complete(); } this.closed_ = true; }; /** * @param {QueuedRequestToken} token Token for this sign request. */ QueuedSignRequest.prototype.setToken = function(token) { /** @private {QueuedRequestToken} */ this.token_ = token; }; /** * Called when this sign request may begin work. * @param {QueuedRequestToken} token Token for this sign request. */ QueuedSignRequest.prototype.begin = function(token) { this.begun_ = true; this.setToken(token); this.signer_ = new Signer(this.factory_, this.timer_, this.origin_, this.signerFailed_.bind(this), this.signerSucceeded_.bind(this), this.tlsChannelId_, this.logMsgUrl_); if (!this.signer_.setChallenges(this.signData_)) { token.complete(); this.errorCb_(GnubbyCodeTypes.BAD_REQUEST); } }; /** * Called when this request's signer fails. * @param {number} code The failure code reported by the signer. * @private */ QueuedSignRequest.prototype.signerFailed_ = function(code) { this.token_.complete(); this.errorCb_(code); }; /** * Called when this request's signer succeeds. * @param {SignChallenge} challenge The challenge that was signed. * @param {string} info The sign result. * @param {string} browserData Browser data JSON * @private */ QueuedSignRequest.prototype.signerSucceeded_ = function(challenge, info, browserData) { this.token_.complete(); this.successCb_(challenge, info, browserData); }; /** * Creates an object to track signing with a gnubby. * @param {!SignHelperFactory} helperFactory Factory to create a sign helper. * @param {Countdown} timer Timer for sign request. * @param {string} origin The origin making the request. * @param {function(number)} errorCb Called when the sign operation fails. * @param {function(SignChallenge, string, string)} successCb Called when the * sign operation succeeds. * @param {string=} opt_tlsChannelId the TLS channel ID, if any, of the origin * making the request. * @param {string=} opt_logMsgUrl The url to post log messages to. * @constructor */ function Signer(helperFactory, timer, origin, errorCb, successCb, opt_tlsChannelId, opt_logMsgUrl) { /** @private {Countdown} */ this.timer_ = timer; /** @private {string} */ this.origin_ = origin; /** @private {function(number)} */ this.errorCb_ = errorCb; /** @private {function(SignChallenge, string, string)} */ this.successCb_ = successCb; /** @private {string|undefined} */ this.tlsChannelId_ = opt_tlsChannelId; /** @private {string|undefined} */ this.logMsgUrl_ = opt_logMsgUrl; /** @private {boolean} */ this.challengesSet_ = false; /** @private {Array.} */ this.pendingChallenges_ = []; /** @private {boolean} */ this.done_ = false; /** @private {Object.} */ this.browserData_ = {}; /** @private {Object.} */ this.serverChallenges_ = {}; // Allow http appIds for http origins. (Broken, but the caller deserves // what they get.) /** @private {boolean} */ this.allowHttp_ = this.origin_ ? this.origin_.indexOf('http://') == 0 : false; // Protect against helper failure with a watchdog. this.createWatchdog_(timer); /** @private {SignHelper} */ this.helper_ = helperFactory.createHelper( timer, this.helperError_.bind(this), this.helperSuccess_.bind(this), this.logMsgUrl_); } /** * Creates a timer with an expiry greater than the expiration time of the given * timer. * @param {Countdown} timer Timeout timer * @private */ Signer.prototype.createWatchdog_ = function(timer) { var millis = timer.millisecondsUntilExpired(); millis += CountdownTimer.TIMER_INTERVAL_MILLIS; /** @private {Countdown|undefined} */ this.watchdogTimer_ = new CountdownTimer(millis, this.timeout_.bind(this)); }; /** * Default timeout value in case the caller never provides a valid timeout. */ Signer.DEFAULT_TIMEOUT_MILLIS = 30 * 1000; /** * Sets the challenges to be signed. * @param {SignData} signData The challenges to set. * @return {boolean} Whether the challenges could be set. */ Signer.prototype.setChallenges = function(signData) { if (this.challengesSet_ || this.done_) return false; /** @private {SignData} */ this.signData_ = signData; /** @private {boolean} */ this.challengesSet_ = true; this.checkAppIds_(); return true; }; /** * Adds new challenges to the challenges being signed. * @param {SignData} signData Challenges to add. * @param {boolean} finalChallenges Whether these are the final challenges. * @return {boolean} Whether the challenge could be added. */ Signer.prototype.addChallenges = function(signData, finalChallenges) { var newChallenges = this.encodeSignChallenges_(signData); for (var i = 0; i < newChallenges.length; i++) { this.pendingChallenges_.push(newChallenges[i]); } if (!finalChallenges) { return true; } return this.helper_.doSign(this.pendingChallenges_); }; /** * Creates challenges for helper from challenges. * @param {Array.} challenges Challenges to add. * @return {Array.} Encoded challenges * @private */ Signer.prototype.encodeSignChallenges_ = function(challenges) { var newChallenges = []; for (var i = 0; i < challenges.length; i++) { var incomingChallenge = challenges[i]; var serverChallenge = incomingChallenge['challenge']; var appId = incomingChallenge['appId']; var encodedKeyHandle = incomingChallenge['keyHandle']; var version = incomingChallenge['version']; var browserData = makeSignBrowserData(serverChallenge, this.origin_, this.tlsChannelId_); var encodedChallenge = makeChallenge(browserData, appId, encodedKeyHandle, version); var key = encodedKeyHandle + encodedChallenge['challengeHash']; this.browserData_[key] = browserData; this.serverChallenges_[key] = incomingChallenge; newChallenges.push(encodedChallenge); } return newChallenges; }; /** * Checks the app ids of incoming requests, and, when this signer is enforcing * that app ids are valid, adds successful challenges to those being signed. * @private */ Signer.prototype.checkAppIds_ = function() { // Check the incoming challenges' app ids. /** @private {Array.<[string, Array.]>} */ this.orderedRequests_ = requestsByAppId(this.signData_); if (!this.orderedRequests_.length) { // Safety check: if the challenges are somehow empty, the helper will never // be fed any data, so the request could never be satisfied. You lose. this.notifyError_(GnubbyCodeTypes.BAD_REQUEST); return; } /** @private {number} */ this.fetchedAppIds_ = 0; /** @private {number} */ this.validAppIds_ = 0; for (var i = 0, appIdRequestsPair; i < this.orderedRequests_.length; i++) { var appIdRequestsPair = this.orderedRequests_[i]; var appId = appIdRequestsPair[0]; var requests = appIdRequestsPair[1]; if (appId == this.origin_) { // Trivially allowed. this.fetchedAppIds_++; this.validAppIds_++; this.addChallenges(requests, this.fetchedAppIds_ == this.orderedRequests_.length); } else { var start = new Date(); fetchAllowedOriginsForAppId(appId, this.allowHttp_, this.fetchedAllowedOriginsForAppId_.bind(this, appId, start, requests)); } } }; /** * Called with the result of an app id fetch. * @param {string} appId the app id that was fetched. * @param {Date} start the time the fetch request started. * @param {Array.} challenges Challenges for this app id. * @param {number} rc The HTTP response code for the app id fetch. * @param {!Array.} allowedOrigins The origins allowed for this app id. * @private */ Signer.prototype.fetchedAllowedOriginsForAppId_ = function(appId, start, challenges, rc, allowedOrigins) { var end = new Date(); logFetchAppIdResult(appId, end - start, allowedOrigins, this.logMsgUrl_); if (rc != 200 && !(rc >= 400 && rc < 500)) { if (this.timer_.expired()) { // Act as though the helper timed out. this.helperError_(DeviceStatusCodes.TIMEOUT_STATUS, false); } else { start = new Date(); fetchAllowedOriginsForAppId(appId, this.allowHttp_, this.fetchedAllowedOriginsForAppId_.bind(this, appId, start, challenges)); } return; } this.fetchedAppIds_++; var finalChallenges = (this.fetchedAppIds_ == this.orderedRequests_.length); if (isValidAppIdForOrigin(appId, this.origin_, allowedOrigins)) { this.validAppIds_++; this.addChallenges(challenges, finalChallenges); } else { logInvalidOriginForAppId(this.origin_, appId, this.logMsgUrl_); // If this is the final request, sign the valid challenges. if (finalChallenges) { if (!this.helper_.doSign(this.pendingChallenges_)) { this.notifyError_(GnubbyCodeTypes.BAD_REQUEST); return; } } } if (finalChallenges && !this.validAppIds_) { // If all app ids are invalid, notify the caller, otherwise implicitly // allow the helper to report whether any of the valid challenges succeeded. this.notifyError_(GnubbyCodeTypes.BAD_APP_ID); } }; /** * Called when the timeout expires on this signer. * @private */ Signer.prototype.timeout_ = function() { this.watchdogTimer_ = undefined; // The web page gets grumpy if it doesn't get WAIT_TOUCH within a reasonable // time. this.notifyError_(GnubbyCodeTypes.WAIT_TOUCH); }; /** Closes this signer. */ Signer.prototype.close = function() { if (this.helper_) this.helper_.close(); }; /** * Notifies the caller of error with the given error code. * @param {number} code Error code * @private */ Signer.prototype.notifyError_ = function(code) { if (this.done_) return; this.close(); this.done_ = true; this.errorCb_(code); }; /** * Notifies the caller of success. * @param {SignChallenge} challenge The challenge that was signed. * @param {string} info The sign result. * @param {string} browserData Browser data JSON * @private */ Signer.prototype.notifySuccess_ = function(challenge, info, browserData) { if (this.done_) return; this.close(); this.done_ = true; this.successCb_(challenge, info, browserData); }; /** * Maps a sign helper's error code namespace to the page's error code namespace. * @param {number} code Error code from DeviceStatusCodes namespace. * @param {boolean} anyGnubbies Whether any gnubbies were found. * @return {number} A GnubbyCodeTypes error code. * @private */ Signer.mapError_ = function(code, anyGnubbies) { var reportedError; switch (code) { case DeviceStatusCodes.WRONG_DATA_STATUS: reportedError = anyGnubbies ? GnubbyCodeTypes.NONE_PLUGGED_ENROLLED : GnubbyCodeTypes.NO_GNUBBIES; break; case DeviceStatusCodes.OK_STATUS: // If the error callback is called with OK, it means the signature was // empty, which we treat the same as... case DeviceStatusCodes.WAIT_TOUCH_STATUS: reportedError = GnubbyCodeTypes.WAIT_TOUCH; break; case DeviceStatusCodes.BUSY_STATUS: reportedError = GnubbyCodeTypes.BUSY; break; default: reportedError = GnubbyCodeTypes.UNKNOWN_ERROR; break; } return reportedError; }; /** * Called by the helper upon error. * @param {number} code Error code * @param {boolean} anyGnubbies If any gnubbies were found * @private */ Signer.prototype.helperError_ = function(code, anyGnubbies) { this.clearTimeout_(); var reportedError = Signer.mapError_(code, anyGnubbies); console.log(UTIL_fmt('helper reported ' + code.toString(16) + ', returning ' + reportedError)); this.notifyError_(reportedError); }; /** * Called by helper upon success. * @param {SignHelperChallenge} challenge The challenge that was signed. * @param {string} info The sign result. * @param {string=} opt_source The source, if any, if the signature. * @private */ Signer.prototype.helperSuccess_ = function(challenge, info, opt_source) { // Got a good reply, kill timer. this.clearTimeout_(); if (this.logMsgUrl_ && opt_source) { var logMsg = 'signed&source=' + opt_source; logMessage(logMsg, this.logMsgUrl_); } var key = challenge['keyHandle'] + challenge['challengeHash']; var browserData = this.browserData_[key]; // Notify with server-provided challenge, not the encoded one: the // server-provided challenge contains additional fields it relies on. var serverChallenge = this.serverChallenges_[key]; this.notifySuccess_(serverChallenge, info, browserData); }; /** * Clears the timeout for this signer. * @private */ Signer.prototype.clearTimeout_ = function() { if (this.watchdogTimer_) { this.watchdogTimer_.clearTimeout(); this.watchdogTimer_ = undefined; } };