| Index: chrome/browser/resources/cryptotoken/enroller.js
|
| diff --git a/chrome/browser/resources/cryptotoken/enroller.js b/chrome/browser/resources/cryptotoken/enroller.js
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..6fc912f596e05d03429afbc03cb138bbfbaff4cc
|
| --- /dev/null
|
| +++ b/chrome/browser/resources/cryptotoken/enroller.js
|
| @@ -0,0 +1,570 @@
|
| +// Copyright (c) 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 enrollment.
|
| + * @author juanlang@google.com (Juan Lang)
|
| + */
|
| +
|
| +'use strict';
|
| +
|
| +/**
|
| + * Handles an enroll request.
|
| + * @param {!EnrollHelperFactory} factory Factory to create an enroll helper.
|
| + * @param {MessageSender} sender The sender of the message.
|
| + * @param {Object} request The web page's enroll request.
|
| + * @param {boolean} enforceAppIdValid Whether to enforce that the appId in the
|
| + * request matches the sender's origin.
|
| + * @param {Function} sendResponse Called back with the result of the enroll.
|
| + * @param {boolean} toleratesMultipleResponses Whether the sendResponse
|
| + * callback can be called more than once, e.g. for progress updates.
|
| + * @return {Closeable}
|
| + */
|
| +function handleEnrollRequest(factory, sender, request, enforceAppIdValid,
|
| + sendResponse, toleratesMultipleResponses) {
|
| + var sentResponse = false;
|
| + function sendResponseOnce(r) {
|
| + if (enroller) {
|
| + enroller.close();
|
| + enroller = 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) {
|
| + console.log(UTIL_fmt('code=' + code));
|
| + var response = formatWebPageResponse(GnubbyMsgTypes.ENROLL_WEB_REPLY, code);
|
| + if (request['requestId']) {
|
| + response['requestId'] = request['requestId'];
|
| + }
|
| + sendResponseOnce(response);
|
| + }
|
| +
|
| + var origin = getOriginFromUrl(/** @type {string} */ (sender.url));
|
| + if (!origin) {
|
| + sendErrorResponse(GnubbyCodeTypes.BAD_REQUEST);
|
| + return null;
|
| + }
|
| +
|
| + if (!isValidEnrollRequest(request)) {
|
| + sendErrorResponse(GnubbyCodeTypes.BAD_REQUEST);
|
| + return null;
|
| + }
|
| +
|
| + var signData = request['signData'];
|
| + var enrollChallenges = request['enrollChallenges'];
|
| + var logMsgUrl = request['logMsgUrl'];
|
| + var timeoutMillis = Enroller.DEFAULT_TIMEOUT_MILLIS;
|
| + if (request['timeout']) {
|
| + // Request timeout is in seconds.
|
| + timeoutMillis = request['timeout'] * 1000;
|
| + }
|
| +
|
| + function findChallengeOfVersion(enrollChallenges, version) {
|
| + for (var i = 0; i < enrollChallenges.length; i++) {
|
| + if (enrollChallenges[i]['version'] == version) {
|
| + return enrollChallenges[i];
|
| + }
|
| + }
|
| + return null;
|
| + }
|
| +
|
| + function sendSuccessResponse(u2fVersion, info, browserData) {
|
| + var enrollChallenge = findChallengeOfVersion(enrollChallenges, u2fVersion);
|
| + if (!enrollChallenge) {
|
| + sendErrorResponse(GnubbyCodeTypes.UNKNOWN_ERROR);
|
| + return;
|
| + }
|
| + var enrollUpdateData = {};
|
| + enrollUpdateData['enrollData'] = info;
|
| + // Echo the used challenge back in the reply.
|
| + for (var k in enrollChallenge) {
|
| + enrollUpdateData[k] = enrollChallenge[k];
|
| + }
|
| + if (u2fVersion == 'U2F_V2') {
|
| + // For U2F_V2, the challenge sent to the gnubby is modified to be the
|
| + // hash of the browser data. Include the browser data.
|
| + enrollUpdateData['browserData'] = browserData;
|
| + }
|
| + var response = formatWebPageResponse(
|
| + GnubbyMsgTypes.ENROLL_WEB_REPLY, GnubbyCodeTypes.OK, enrollUpdateData);
|
| + sendResponseOnce(response);
|
| + }
|
| +
|
| + function sendNotification(code) {
|
| + console.log(UTIL_fmt('notification, code=' + code));
|
| + // Can the callback handle progress updates? If so, send one.
|
| + if (toleratesMultipleResponses) {
|
| + var response = formatWebPageResponse(
|
| + GnubbyMsgTypes.ENROLL_WEB_NOTIFICATION, code);
|
| + if (request['requestId']) {
|
| + response['requestId'] = request['requestId'];
|
| + }
|
| + sendResponse(response);
|
| + }
|
| + }
|
| +
|
| + var timer = new CountdownTimer(timeoutMillis);
|
| + var enroller = new Enroller(factory, timer, origin, sendErrorResponse,
|
| + sendSuccessResponse, sendNotification, sender.tlsChannelId, logMsgUrl);
|
| + enroller.doEnroll(enrollChallenges, signData, enforceAppIdValid);
|
| + return /** @type {Closeable} */ (enroller);
|
| +}
|
| +
|
| +/**
|
| + * Returns whether the request appears to be a valid enroll request.
|
| + * @param {Object} request the request.
|
| + * @return {boolean} whether the request appears valid.
|
| + */
|
| +function isValidEnrollRequest(request) {
|
| + if (!request.hasOwnProperty('enrollChallenges'))
|
| + return false;
|
| + var enrollChallenges = request['enrollChallenges'];
|
| + if (!enrollChallenges.length)
|
| + return false;
|
| + var seenVersions = {};
|
| + for (var i = 0; i < enrollChallenges.length; i++) {
|
| + var enrollChallenge = enrollChallenges[i];
|
| + var version = enrollChallenge['version'];
|
| + if (!version) {
|
| + // Version is implicitly V1 if not specified.
|
| + version = 'U2F_V1';
|
| + }
|
| + if (version != 'U2F_V1' && version != 'U2F_V2') {
|
| + return false;
|
| + }
|
| + if (seenVersions[version]) {
|
| + // Each version can appear at most once.
|
| + return false;
|
| + }
|
| + seenVersions[version] = version;
|
| + if (!enrollChallenge['appId']) {
|
| + return false;
|
| + }
|
| + if (!enrollChallenge['challenge']) {
|
| + // The challenge is required.
|
| + return false;
|
| + }
|
| + }
|
| + var signData = request['signData'];
|
| + // An empty signData is ok, in the case the user is not already enrolled.
|
| + if (signData && !isValidSignData(signData))
|
| + return false;
|
| + return true;
|
| +}
|
| +
|
| +/**
|
| + * Creates a new object to track enrolling with a gnubby.
|
| + * @param {!EnrollHelperFactory} helperFactory factory to create an enroll
|
| + * helper.
|
| + * @param {!Countdown} timer Timer for enroll request.
|
| + * @param {string} origin The origin making the request.
|
| + * @param {function(number)} errorCb Called upon enroll failure with an error
|
| + * code.
|
| + * @param {function(string, string, (string|undefined))} successCb Called upon
|
| + * enroll success with the version of the succeeding gnubby, the enroll
|
| + * data, and optionally the browser data associated with the enrollment.
|
| + * @param {(function(number)|undefined)} opt_progressCb Called with progress
|
| + * updates to the enroll request.
|
| + * @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 Enroller(helperFactory, timer, origin, errorCb, successCb,
|
| + opt_progressCb, opt_tlsChannelId, opt_logMsgUrl) {
|
| + /** @private {Countdown} */
|
| + this.timer_ = timer;
|
| + /** @private {string} */
|
| + this.origin_ = origin;
|
| + /** @private {function(number)} */
|
| + this.errorCb_ = errorCb;
|
| + /** @private {function(string, string, (string|undefined))} */
|
| + this.successCb_ = successCb;
|
| + /** @private {(function(number)|undefined)} */
|
| + this.progressCb_ = opt_progressCb;
|
| + /** @private {string|undefined} */
|
| + this.tlsChannelId_ = opt_tlsChannelId;
|
| + /** @private {string|undefined} */
|
| + this.logMsgUrl_ = opt_logMsgUrl;
|
| +
|
| + /** @private {boolean} */
|
| + this.done_ = false;
|
| + /** @private {number|undefined} */
|
| + this.lastProgressUpdate_ = undefined;
|
| +
|
| + /** @private {Object.<string, string>} */
|
| + this.browserData_ = {};
|
| + /** @private {Array.<EnrollHelperChallenge>} */
|
| + this.encodedEnrollChallenges_ = [];
|
| + /** @private {Array.<SignHelperChallenge>} */
|
| + this.encodedSignChallenges_ = [];
|
| + // 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;
|
| +
|
| + /** @private {EnrollHelper} */
|
| + this.helper_ = helperFactory.createHelper(timer,
|
| + this.helperError_.bind(this), this.helperSuccess_.bind(this),
|
| + this.helperProgress_.bind(this));
|
| +}
|
| +
|
| +/**
|
| + * Default timeout value in case the caller never provides a valid timeout.
|
| + */
|
| +Enroller.DEFAULT_TIMEOUT_MILLIS = 30 * 1000;
|
| +
|
| +/**
|
| + * Performs an enroll request with the given enroll and sign challenges.
|
| + * @param {Array.<Object>} enrollChallenges
|
| + * @param {Array.<Object>} signChallenges
|
| + * @param {boolean} enforceAppIdValid
|
| + */
|
| +Enroller.prototype.doEnroll =
|
| + function(enrollChallenges, signChallenges, enforceAppIdValid) {
|
| + this.setEnrollChallenges_(enrollChallenges);
|
| + this.setSignChallenges_(signChallenges);
|
| +
|
| + if (!enforceAppIdValid) {
|
| + // If not enforcing app id validity, begin enrolling right away.
|
| + this.helper_.doEnroll(this.encodedEnrollChallenges_,
|
| + this.encodedSignChallenges_);
|
| + }
|
| + // Whether or not enforcing app id validity, begin fetching/checking the
|
| + // app ids.
|
| + var enrollAppIds = [];
|
| + for (var i = 0; i < enrollChallenges.length; i++) {
|
| + enrollAppIds.push(enrollChallenges[i]['appId']);
|
| + }
|
| + var self = this;
|
| + this.checkAppIds_(enrollAppIds, signChallenges, function(result) {
|
| + if (!enforceAppIdValid) {
|
| + // Nothing to do, move along.
|
| + return;
|
| + }
|
| + if (result) {
|
| + self.helper_.doEnroll(self.encodedEnrollChallenges_,
|
| + self.encodedSignChallenges_);
|
| + } else {
|
| + self.notifyError_(GnubbyCodeTypes.BAD_APP_ID);
|
| + }
|
| + });
|
| +};
|
| +
|
| +/**
|
| + * Encodes the enroll challenges for use by an enroll helper.
|
| + * @param {Array.<Object>} enrollChallenges
|
| + * @return {Array.<EnrollHelperChallenge>} the encoded challenges.
|
| + * @private
|
| + */
|
| +Enroller.encodeEnrollChallenges_ = function(enrollChallenges) {
|
| + var encodedChallenges = [];
|
| + for (var i = 0; i < enrollChallenges.length; i++) {
|
| + var enrollChallenge = enrollChallenges[i];
|
| + var encodedChallenge = {};
|
| + var version;
|
| + if (enrollChallenge['version']) {
|
| + version = enrollChallenge['version'];
|
| + } else {
|
| + // Version is implicitly V1 if not specified.
|
| + version = 'U2F_V1';
|
| + }
|
| + encodedChallenge['version'] = version;
|
| + encodedChallenge['challenge'] = enrollChallenge['challenge'];
|
| + encodedChallenge['appIdHash'] =
|
| + B64_encode(sha256HashOfString(enrollChallenge['appId']));
|
| + encodedChallenges.push(encodedChallenge);
|
| + }
|
| + return encodedChallenges;
|
| +};
|
| +
|
| +/**
|
| + * Sets this enroller's enroll challenges.
|
| + * @param {Array.<Object>} enrollChallenges The enroll challenges.
|
| + * @private
|
| + */
|
| +Enroller.prototype.setEnrollChallenges_ = function(enrollChallenges) {
|
| + var challenges = [];
|
| + for (var i = 0; i < enrollChallenges.length; i++) {
|
| + var enrollChallenge = enrollChallenges[i];
|
| + var version = enrollChallenge.version;
|
| + if (!version) {
|
| + // Version is implicitly V1 if not specified.
|
| + version = 'U2F_V1';
|
| + }
|
| +
|
| + if (version == 'U2F_V2') {
|
| + var modifiedChallenge = {};
|
| + for (var k in enrollChallenge) {
|
| + modifiedChallenge[k] = enrollChallenge[k];
|
| + }
|
| + // V2 enroll responses contain signatures over a browser data object,
|
| + // which we're constructing here. The browser data object contains, among
|
| + // other things, the server challenge.
|
| + var serverChallenge = enrollChallenge['challenge'];
|
| + var browserData = makeEnrollBrowserData(
|
| + serverChallenge, this.origin_, this.tlsChannelId_);
|
| + // Replace the challenge with the hash of the browser data.
|
| + modifiedChallenge['challenge'] =
|
| + B64_encode(sha256HashOfString(browserData));
|
| + this.browserData_[version] =
|
| + B64_encode(UTIL_StringToBytes(browserData));
|
| + challenges.push(modifiedChallenge);
|
| + } else {
|
| + challenges.push(enrollChallenge);
|
| + }
|
| + }
|
| + // Store the encoded challenges for use by the enroll helper.
|
| + this.encodedEnrollChallenges_ =
|
| + Enroller.encodeEnrollChallenges_(challenges);
|
| +};
|
| +
|
| +/**
|
| + * Sets this enroller's sign data.
|
| + * @param {Array=} signData the sign challenges to add.
|
| + * @private
|
| + */
|
| +Enroller.prototype.setSignChallenges_ = function(signData) {
|
| + this.encodedSignChallenges_ = [];
|
| + if (signData) {
|
| + for (var i = 0; i < signData.length; i++) {
|
| + var incomingChallenge = signData[i];
|
| + var serverChallenge = incomingChallenge['challenge'];
|
| + var appId = incomingChallenge['appId'];
|
| + var encodedKeyHandle = incomingChallenge['keyHandle'];
|
| +
|
| + var challenge = makeChallenge(serverChallenge, appId, encodedKeyHandle,
|
| + incomingChallenge['version']);
|
| +
|
| + this.encodedSignChallenges_.push(challenge);
|
| + }
|
| + }
|
| +};
|
| +
|
| +/**
|
| + * Checks the app ids associated with this enroll request, and calls a callback
|
| + * with the result of the check.
|
| + * @param {!Array.<string>} enrollAppIds The app ids in the enroll challenge
|
| + * portion of the enroll request.
|
| + * @param {SignData} signData The sign data associated with the request.
|
| + * @param {function(boolean)} cb Called with the result of the check.
|
| + * @private
|
| + */
|
| +Enroller.prototype.checkAppIds_ = function(enrollAppIds, signData, cb) {
|
| + if (!enrollAppIds || !enrollAppIds.length) {
|
| + // Defensive programming check: the enroll request is required to contain
|
| + // its own app ids, so if there aren't any, reject the request.
|
| + cb(false);
|
| + return;
|
| + }
|
| +
|
| + /** @private {Array.<string>} */
|
| + this.distinctAppIds_ =
|
| + UTIL_unionArrays(enrollAppIds, getDistinctAppIds(signData));
|
| + /** @private {boolean} */
|
| + this.anyInvalidAppIds_ = false;
|
| + /** @private {boolean} */
|
| + this.appIdFailureReported_ = false;
|
| + /** @private {number} */
|
| + this.fetchedAppIds_ = 0;
|
| +
|
| + for (var i = 0; i < this.distinctAppIds_.length; i++) {
|
| + var appId = this.distinctAppIds_[i];
|
| + if (appId == this.origin_) {
|
| + // Trivially allowed.
|
| + this.fetchedAppIds_++;
|
| + if (this.fetchedAppIds_ == this.distinctAppIds_.length &&
|
| + !this.anyInvalidAppIds_) {
|
| + // Last app id was fetched, and they were all valid: we're done.
|
| + // (Note that the case when anyInvalidAppIds_ is true doesn't need to
|
| + // be handled here: the callback was already called with false at that
|
| + // point, see fetchedAllowedOriginsForAppId_.)
|
| + cb(true);
|
| + }
|
| + } else {
|
| + var start = new Date();
|
| + fetchAllowedOriginsForAppId(appId, this.allowHttp_,
|
| + this.fetchedAllowedOriginsForAppId_.bind(this, appId, start, cb));
|
| + }
|
| + }
|
| +};
|
| +
|
| +/**
|
| + * 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 {function(boolean)} cb Called with the result of the app id check.
|
| + * @param {number} rc The HTTP response code for the app id fetch.
|
| + * @param {!Array.<string>} allowedOrigins The origins allowed for this app id.
|
| + * @private
|
| + */
|
| +Enroller.prototype.fetchedAllowedOriginsForAppId_ =
|
| + function(appId, start, cb, rc, allowedOrigins) {
|
| + var end = new Date();
|
| + this.fetchedAppIds_++;
|
| + 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, cb));
|
| + }
|
| + return;
|
| + }
|
| + if (!isValidAppIdForOrigin(appId, this.origin_, allowedOrigins)) {
|
| + logInvalidOriginForAppId(this.origin_, appId, this.logMsgUrl_);
|
| + this.anyInvalidAppIds_ = true;
|
| + if (!this.appIdFailureReported_) {
|
| + // Only the failure case can happen more than once, so only report
|
| + // it the first time.
|
| + this.appIdFailureReported_ = true;
|
| + cb(false);
|
| + }
|
| + }
|
| + if (this.fetchedAppIds_ == this.distinctAppIds_.length &&
|
| + !this.anyInvalidAppIds_) {
|
| + // Last app id was fetched, and they were all valid: we're done.
|
| + cb(true);
|
| + }
|
| +};
|
| +
|
| +/** Closes this enroller. */
|
| +Enroller.prototype.close = function() {
|
| + if (this.helper_) this.helper_.close();
|
| +};
|
| +
|
| +/**
|
| + * Notifies the caller with the error code.
|
| + * @param {number} code
|
| + * @private
|
| + */
|
| +Enroller.prototype.notifyError_ = function(code) {
|
| + if (this.done_)
|
| + return;
|
| + this.close();
|
| + this.done_ = true;
|
| + this.errorCb_(code);
|
| +};
|
| +
|
| +/**
|
| + * Notifies the caller of success with the provided response data.
|
| + * @param {string} u2fVersion
|
| + * @param {string} info
|
| + * @param {string|undefined} opt_browserData
|
| + * @private
|
| + */
|
| +Enroller.prototype.notifySuccess_ =
|
| + function(u2fVersion, info, opt_browserData) {
|
| + if (this.done_)
|
| + return;
|
| + this.close();
|
| + this.done_ = true;
|
| + this.successCb_(u2fVersion, info, opt_browserData);
|
| +};
|
| +
|
| +/**
|
| + * Notifies the caller of progress with the error code.
|
| + * @param {number} code
|
| + * @private
|
| + */
|
| +Enroller.prototype.notifyProgress_ = function(code) {
|
| + if (this.done_)
|
| + return;
|
| + if (code != this.lastProgressUpdate_) {
|
| + this.lastProgressUpdate_ = code;
|
| + // If there is no progress callback, treat it like an error and clean up.
|
| + if (this.progressCb_) {
|
| + this.progressCb_(code);
|
| + } else {
|
| + this.notifyError_(code);
|
| + }
|
| + }
|
| +};
|
| +
|
| +/**
|
| + * Maps an enroll 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
|
| + */
|
| +Enroller.mapError_ = function(code, anyGnubbies) {
|
| + var reportedError = GnubbyCodeTypes.UNKNOWN_ERROR;
|
| + switch (code) {
|
| + case DeviceStatusCodes.WRONG_DATA_STATUS:
|
| + reportedError = anyGnubbies ? GnubbyCodeTypes.ALREADY_ENROLLED :
|
| + GnubbyCodeTypes.NO_GNUBBIES;
|
| + break;
|
| +
|
| + case DeviceStatusCodes.WAIT_TOUCH_STATUS:
|
| + reportedError = GnubbyCodeTypes.WAIT_TOUCH;
|
| + break;
|
| +
|
| + case DeviceStatusCodes.BUSY_STATUS:
|
| + reportedError = GnubbyCodeTypes.BUSY;
|
| + break;
|
| + }
|
| + return reportedError;
|
| +};
|
| +
|
| +/**
|
| + * Called by the helper upon error.
|
| + * @param {number} code
|
| + * @param {boolean} anyGnubbies
|
| + * @private
|
| + */
|
| +Enroller.prototype.helperError_ = function(code, anyGnubbies) {
|
| + var reportedError = Enroller.mapError_(code, anyGnubbies);
|
| + console.log(UTIL_fmt('helper reported ' + code.toString(16) +
|
| + ', returning ' + reportedError));
|
| + this.notifyError_(reportedError);
|
| +};
|
| +
|
| +/**
|
| + * Called by helper upon success.
|
| + * @param {string} u2fVersion gnubby version.
|
| + * @param {string} info enroll data.
|
| + * @private
|
| + */
|
| +Enroller.prototype.helperSuccess_ = function(u2fVersion, info) {
|
| + console.log(UTIL_fmt('Gnubby enrollment succeeded!!!!!'));
|
| +
|
| + var browserData;
|
| + if (u2fVersion == 'U2F_V2') {
|
| + // For U2F_V2, the challenge sent to the gnubby is modified to be the hash
|
| + // of the browser data. Include the browser data.
|
| + browserData = this.browserData_[u2fVersion];
|
| + }
|
| +
|
| + this.notifySuccess_(u2fVersion, info, browserData);
|
| +};
|
| +
|
| +/**
|
| + * Called by helper to notify progress.
|
| + * @param {number} code
|
| + * @param {boolean} anyGnubbies
|
| + * @private
|
| + */
|
| +Enroller.prototype.helperProgress_ = function(code, anyGnubbies) {
|
| + var reportedError = Enroller.mapError_(code, anyGnubbies);
|
| + console.log(UTIL_fmt('helper notified ' + code.toString(16) +
|
| + ', returning ' + reportedError));
|
| + this.notifyProgress_(reportedError);
|
| +};
|
|
|