blob: f0af2a17ed4f64fd21c7b68d7476340d0d88ae60 [file] [log] [blame]
// 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 web sign request.
* @param {MessageSender} messageSender The message sender.
* @param {Object} request The web page's sign request.
* @param {Function} sendResponse Called back with the result of the sign.
* @return {Closeable} Request handler that should be closed when the browser
* message channel is closed.
*/
function handleWebSignRequest(messageSender, request, sendResponse) {
var sentResponse = false;
var queuedSignRequest;
function sendErrorResponse(error) {
sendResponseOnce(sentResponse, queuedSignRequest,
makeWebErrorResponse(request,
mapErrorCodeToGnubbyCodeType(error.errorCode, true /* forSign */)),
sendResponse);
}
function sendSuccessResponse(challenge, info, browserData) {
var responseData = makeWebSignResponseDataFromChallenge(challenge);
addSignatureAndBrowserDataToResponseData(responseData, info, browserData,
'browserData');
var response = makeWebSuccessResponse(request, responseData);
sendResponseOnce(sentResponse, queuedSignRequest, response, sendResponse);
}
var sender = createSenderFromMessageSender(messageSender);
if (!sender) {
sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST});
return null;
}
queuedSignRequest =
validateAndEnqueueSignRequest(
sender, request, 'signData', sendErrorResponse,
sendSuccessResponse);
return queuedSignRequest;
}
/**
* Handles a U2F sign request.
* @param {MessageSender} messageSender The message sender.
* @param {Object} request The web page's sign request.
* @param {Function} sendResponse Called back with the result of the sign.
* @return {Closeable} Request handler that should be closed when the browser
* message channel is closed.
*/
function handleU2fSignRequest(messageSender, request, sendResponse) {
var sentResponse = false;
var queuedSignRequest;
function sendErrorResponse(error) {
sendResponseOnce(sentResponse, queuedSignRequest,
makeU2fErrorResponse(request, error.errorCode, error.errorMessage),
sendResponse);
}
function sendSuccessResponse(challenge, info, browserData) {
var responseData = makeU2fSignResponseDataFromChallenge(challenge);
addSignatureAndBrowserDataToResponseData(responseData, info, browserData,
'clientData');
var response = makeU2fSuccessResponse(request, responseData);
sendResponseOnce(sentResponse, queuedSignRequest, response, sendResponse);
}
var sender = createSenderFromMessageSender(messageSender);
if (!sender) {
sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST});
return null;
}
queuedSignRequest =
validateAndEnqueueSignRequest(
sender, request, 'signRequests', sendErrorResponse,
sendSuccessResponse);
return queuedSignRequest;
}
/**
* Creates a base U2F responseData object from the server challenge.
* @param {SignChallenge} challenge The server challenge.
* @return {Object} The responseData object.
*/
function makeU2fSignResponseDataFromChallenge(challenge) {
var responseData = {
'keyHandle': challenge['keyHandle']
};
return responseData;
}
/**
* Creates a base web responseData object from the server challenge.
* @param {SignChallenge} challenge The server challenge.
* @return {Object} The responseData object.
*/
function makeWebSignResponseDataFromChallenge(challenge) {
var responseData = {};
for (var k in challenge) {
responseData[k] = challenge[k];
}
return responseData;
}
/**
* Adds the browser data and signature values to a responseData object.
* @param {Object} responseData The "base" responseData object.
* @param {string} signatureData The signature data.
* @param {string} browserData The browser data generated from the challenge.
* @param {string} browserDataName The name of the browser data key in the
* responseData object.
*/
function addSignatureAndBrowserDataToResponseData(responseData, signatureData,
browserData, browserDataName) {
responseData[browserDataName] = B64_encode(UTIL_StringToBytes(browserData));
responseData['signatureData'] = signatureData;
}
/**
* Validates a sign request using the given sign challenges name, and, if valid,
* enqueues the sign request for eventual processing.
* @param {WebRequestSender} sender The sender of the message.
* @param {Object} request The web page's sign request.
* @param {string} signChallengesName The name of the sign challenges value in
* the request.
* @param {function(U2fError)} errorCb Error callback.
* @param {function(SignChallenge, string, string)} successCb Success callback.
* @return {Closeable} Request handler that should be closed when the browser
* message channel is closed.
*/
function validateAndEnqueueSignRequest(sender, request,
signChallengesName, errorCb, successCb) {
function timeout() {
errorCb({errorCode: ErrorCodes.TIMEOUT});
}
if (!isValidSignRequest(request, signChallengesName)) {
errorCb({errorCode: ErrorCodes.BAD_REQUEST});
return null;
}
var signChallenges = request[signChallengesName];
var appId;
if (request['appId']) {
appId = request['appId'];
} else if (signChallenges.length) {
appId = signChallenges[0]['appId'];
}
// Sanity check
if (!appId) {
console.warn(UTIL_fmt('empty sign appId?'));
errorCb({errorCode: ErrorCodes.BAD_REQUEST});
return null;
}
var timeoutValueSeconds = getTimeoutValueFromRequest(request);
// Attenuate watchdog timeout value less than the signer's timeout, so the
// watchdog only fires after the signer could reasonably have called back,
// not before.
timeoutValueSeconds = attenuateTimeoutInSeconds(timeoutValueSeconds,
MINIMUM_TIMEOUT_ATTENUATION_SECONDS / 2);
var watchdog = new WatchdogRequestHandler(timeoutValueSeconds, timeout);
var wrappedErrorCb = watchdog.wrapCallback(errorCb);
var wrappedSuccessCb = watchdog.wrapCallback(successCb);
var timer = createAttenuatedTimer(
FACTORY_REGISTRY.getCountdownFactory(), timeoutValueSeconds);
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(signChallenges,
timer, sender, wrappedErrorCb, wrappedSuccessCb, request['challenge'],
appId, logMsgUrl);
var requestToken = signRequestQueue.queueRequest(appId, sender.origin,
queuedSignRequest.begin.bind(queuedSignRequest), timer);
queuedSignRequest.setToken(requestToken);
watchdog.setCloseable(queuedSignRequest);
return watchdog;
}
/**
* Returns whether the request appears to be a valid sign request.
* @param {Object} request The request.
* @param {string} signChallengesName The name of the sign challenges value in
* the request.
* @return {boolean} Whether the request appears valid.
*/
function isValidSignRequest(request, signChallengesName) {
if (!request.hasOwnProperty(signChallengesName))
return false;
var signChallenges = request[signChallengesName];
var hasDefaultChallenge = request.hasOwnProperty('challenge');
var hasAppId = request.hasOwnProperty('appId');
// If the sign challenge array is empty, the global appId is required.
if (!hasAppId && (!signChallenges || !signChallenges.length)) {
return false;
}
return isValidSignChallengeArray(signChallenges, hasDefaultChallenge,
!hasAppId);
}
/**
* Adapter class representing a queued sign request.
* @param {!Array.<SignChallenge>} signChallenges The sign challenges.
* @param {Countdown} timer Timeout timer
* @param {WebRequestSender} sender Message sender.
* @param {function(U2fError)} errorCb Error callback
* @param {function(SignChallenge, string, string)} successCb Success callback
* @param {string|undefined} opt_defaultChallenge A default sign challenge
* value, if a request does not provide one.
* @param {string|undefined} opt_appId The app id for the entire request.
* @param {string|undefined} opt_logMsgUrl Url to post log messages to
* @constructor
* @implements {Closeable}
*/
function QueuedSignRequest(signChallenges, timer, sender, errorCb,
successCb, opt_defaultChallenge, opt_appId, opt_logMsgUrl) {
/** @private {!Array.<SignChallenge>} */
this.signChallenges_ = signChallenges;
/** @private {Countdown} */
this.timer_ = timer.clone(this.close.bind(this));
/** @private {WebRequestSender} */
this.sender_ = sender;
/** @private {function(U2fError)} */
this.errorCb_ = errorCb;
/** @private {function(SignChallenge, string, string)} */
this.successCb_ = successCb;
/** @private {string|undefined} */
this.defaultChallenge_ = opt_defaultChallenge;
/** @private {string|undefined} */
this.appId_ = opt_appId;
/** @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;
var hadBegunSigning = false;
if (this.begun_ && this.signer_) {
this.signer_.close();
hadBegunSigning = true;
}
if (this.token_) {
if (hadBegunSigning) {
console.log(UTIL_fmt('closing in-progress request'));
} else {
console.log(UTIL_fmt('closing timed-out request before processing'));
}
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) {
if (this.timer_.expired()) {
console.log(UTIL_fmt('Queued request begun after timeout'));
this.close();
this.errorCb_({errorCode: ErrorCodes.TIMEOUT});
return;
}
this.begun_ = true;
this.setToken(token);
this.signer_ = new Signer(this.timer_, this.sender_,
this.signerFailed_.bind(this), this.signerSucceeded_.bind(this),
this.logMsgUrl_);
if (!this.signer_.setChallenges(this.signChallenges_, this.defaultChallenge_,
this.appId_)) {
token.complete();
this.errorCb_({errorCode: ErrorCodes.BAD_REQUEST});
}
// Signer now has responsibility for maintaining timeout.
this.timer_.clearTimeout();
};
/**
* Called when this request's signer fails.
* @param {U2fError} error The failure reported by the signer.
* @private
*/
QueuedSignRequest.prototype.signerFailed_ = function(error) {
this.token_.complete();
this.errorCb_(error);
};
/**
* 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 {Countdown} timer Timer for sign request.
* @param {WebRequestSender} sender The message sender.
* @param {function(U2fError)} errorCb Called when the sign operation fails.
* @param {function(SignChallenge, string, string)} successCb Called when the
* sign operation succeeds.
* @param {string=} opt_logMsgUrl The url to post log messages to.
* @constructor
*/
function Signer(timer, sender, errorCb, successCb, opt_logMsgUrl) {
/** @private {Countdown} */
this.timer_ = timer.clone();
/** @private {WebRequestSender} */
this.sender_ = sender;
/** @private {function(U2fError)} */
this.errorCb_ = errorCb;
/** @private {function(SignChallenge, string, string)} */
this.successCb_ = successCb;
/** @private {string|undefined} */
this.logMsgUrl_ = opt_logMsgUrl;
/** @private {boolean} */
this.challengesSet_ = false;
/** @private {boolean} */
this.done_ = false;
/** @private {Object.<string, string>} */
this.browserData_ = {};
/** @private {Object.<string, SignChallenge>} */
this.serverChallenges_ = {};
// Allow http appIds for http origins. (Broken, but the caller deserves
// what they get.)
/** @private {boolean} */
this.allowHttp_ = this.sender_.origin ?
this.sender_.origin.indexOf('http://') == 0 : false;
/** @private {Closeable} */
this.handler_ = null;
}
/**
* Sets the challenges to be signed.
* @param {Array.<SignChallenge>} signChallenges The challenges to set.
* @param {string=} opt_defaultChallenge A default sign challenge
* value, if a request does not provide one.
* @param {string=} opt_appId The app id for the entire request.
* @return {boolean} Whether the challenges could be set.
*/
Signer.prototype.setChallenges = function(signChallenges, opt_defaultChallenge,
opt_appId) {
if (this.challengesSet_ || this.done_)
return false;
if (this.timer_.expired()) {
this.notifyError_({errorCode: ErrorCodes.TIMEOUT});
return true;
}
/** @private {Array.<SignChallenge>} */
this.signChallenges_ = signChallenges;
/** @private {string|undefined} */
this.defaultChallenge_ = opt_defaultChallenge;
/** @private {string|undefined} */
this.appId_ = opt_appId;
/** @private {boolean} */
this.challengesSet_ = true;
this.checkAppIds_();
return true;
};
/**
* Checks the app ids of incoming requests.
* @private
*/
Signer.prototype.checkAppIds_ = function() {
var appIds = getDistinctAppIds(this.signChallenges_);
if (this.appId_) {
appIds = UTIL_unionArrays([this.appId_], appIds);
}
if (!appIds || !appIds.length) {
var error = {
errorCode: ErrorCodes.BAD_REQUEST,
errorMessage: 'missing appId'
};
this.notifyError_(error);
return;
}
FACTORY_REGISTRY.getOriginChecker()
.canClaimAppIds(this.sender_.origin, appIds)
.then(this.originChecked_.bind(this, appIds));
};
/**
* Called with the result of checking the origin. When the origin is allowed
* to claim the app ids, begins checking whether the app ids also list the
* origin.
* @param {!Array.<string>} appIds The app ids.
* @param {boolean} result Whether the origin could claim the app ids.
* @private
*/
Signer.prototype.originChecked_ = function(appIds, result) {
if (!result) {
var error = {
errorCode: ErrorCodes.BAD_REQUEST,
errorMessage: 'bad appId'
};
this.notifyError_(error);
return;
}
/** @private {!AppIdChecker} */
this.appIdChecker_ = new AppIdChecker(FACTORY_REGISTRY.getTextFetcher(),
this.timer_.clone(), this.sender_.origin,
/** @type {!Array.<string>} */ (appIds), this.allowHttp_,
this.logMsgUrl_);
this.appIdChecker_.doCheck().then(this.appIdChecked_.bind(this));
};
/**
* Called with the result of checking app ids. When the app ids are valid,
* adds the sign challenges to those being signed.
* @param {boolean} result Whether the app ids are valid.
* @private
*/
Signer.prototype.appIdChecked_ = function(result) {
if (!result) {
var error = {
errorCode: ErrorCodes.BAD_REQUEST,
errorMessage: 'bad appId'
};
this.notifyError_(error);
return;
}
if (!this.doSign_()) {
this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
return;
}
};
/**
* Begins signing this signer's challenges.
* @return {boolean} Whether the challenge could be added.
* @private
*/
Signer.prototype.doSign_ = function() {
// Create the browser data for each challenge.
for (var i = 0; i < this.signChallenges_.length; i++) {
var challenge = this.signChallenges_[i];
var serverChallenge;
if (challenge.hasOwnProperty('challenge')) {
serverChallenge = challenge['challenge'];
} else {
serverChallenge = this.defaultChallenge_;
}
if (!serverChallenge) {
console.warn(UTIL_fmt('challenge missing'));
return false;
}
var keyHandle = challenge['keyHandle'];
var browserData =
makeSignBrowserData(serverChallenge, this.sender_.origin,
this.sender_.tlsChannelId);
this.browserData_[keyHandle] = browserData;
this.serverChallenges_[keyHandle] = challenge;
}
var encodedChallenges = encodeSignChallenges(this.signChallenges_,
this.defaultChallenge_, this.appId_, this.getChallengeHash_.bind(this));
var timeoutSeconds = this.timer_.millisecondsUntilExpired() / 1000.0;
var request = makeSignHelperRequest(encodedChallenges, timeoutSeconds,
this.logMsgUrl_);
this.handler_ =
FACTORY_REGISTRY.getRequestHelper()
.getHandler(/** @type {HelperRequest} */ (request));
if (!this.handler_)
return false;
return this.handler_.run(this.helperComplete_.bind(this));
};
/**
* @param {string} keyHandle The key handle used with the challenge.
* @param {string} challenge The challenge.
* @return {string} The hashed challenge associated with the key
* handle/challenge pair.
* @private
*/
Signer.prototype.getChallengeHash_ = function(keyHandle, challenge) {
return B64_encode(sha256HashOfString(this.browserData_[keyHandle]));
};
/** Closes this signer. */
Signer.prototype.close = function() {
this.close_();
};
/**
* Closes this signer, and optionally notifies the caller of error.
* @param {boolean=} opt_notifying When true, this method is being called in the
* process of notifying the caller of an existing status. When false,
* the caller is notified with a default error value, ErrorCodes.TIMEOUT.
* @private
*/
Signer.prototype.close_ = function(opt_notifying) {
if (this.appIdChecker_) {
this.appIdChecker_.close();
}
if (this.handler_) {
this.handler_.close();
this.handler_ = null;
}
this.timer_.clearTimeout();
if (!opt_notifying) {
this.notifyError_({errorCode: ErrorCodes.TIMEOUT});
}
};
/**
* Notifies the caller of error.
* @param {U2fError} error Error.
* @private
*/
Signer.prototype.notifyError_ = function(error) {
if (this.done_)
return;
this.done_ = true;
this.close_(true);
this.errorCb_(error);
};
/**
* 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.done_ = true;
this.close_(true);
this.successCb_(challenge, info, browserData);
};
/**
* Called by the helper upon completion.
* @param {HelperReply} helperReply The result of the sign request.
* @param {string=} opt_source The source of the sign result.
* @private
*/
Signer.prototype.helperComplete_ = function(helperReply, opt_source) {
if (helperReply.type != 'sign_helper_reply') {
this.notifyError_({errorCode: ErrorCodes.OTHER_ERROR});
return;
}
var reply = /** @type {SignHelperReply} */ (helperReply);
if (reply.code) {
var reportedError = mapDeviceStatusCodeToU2fError(reply.code);
console.log(UTIL_fmt('helper reported ' + reply.code.toString(16) +
', returning ' + reportedError.errorCode));
this.notifyError_(reportedError);
} else {
if (this.logMsgUrl_ && opt_source) {
var logMsg = 'signed&source=' + opt_source;
logMessage(logMsg, this.logMsgUrl_);
}
var key = reply.responseData['keyHandle'];
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, reply.responseData.signatureData,
browserData);
}
};