Home | History | Annotate | Download | only in cryptotoken
      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 an enroll request.
     13  * @param {!EnrollHelperFactory} factory Factory to create an enroll helper.
     14  * @param {MessageSender} sender The sender of the message.
     15  * @param {Object} request The web page's enroll request.
     16  * @param {Function} sendResponse Called back with the result of the enroll.
     17  * @param {boolean} toleratesMultipleResponses Whether the sendResponse
     18  *     callback can be called more than once, e.g. for progress updates.
     19  * @return {Closeable} A handler object to be closed when the browser channel
     20  *     closes.
     21  */
     22 function handleEnrollRequest(factory, sender, request, sendResponse,
     23     toleratesMultipleResponses) {
     24   var sentResponse = false;
     25   function sendResponseOnce(r) {
     26     if (enroller) {
     27       enroller.close();
     28       enroller = null;
     29     }
     30     if (!sentResponse) {
     31       sentResponse = true;
     32       try {
     33         // If the page has gone away or the connection has otherwise gone,
     34         // sendResponse fails.
     35         sendResponse(r);
     36       } catch (exception) {
     37         console.warn('sendResponse failed: ' + exception);
     38       }
     39     } else {
     40       console.warn(UTIL_fmt('Tried to reply more than once! Juan, FIX ME'));
     41     }
     42   }
     43 
     44   function sendErrorResponse(code) {
     45     console.log(UTIL_fmt('code=' + code));
     46     var response = formatWebPageResponse(GnubbyMsgTypes.ENROLL_WEB_REPLY, code);
     47     if (request['requestId']) {
     48       response['requestId'] = request['requestId'];
     49     }
     50     sendResponseOnce(response);
     51   }
     52 
     53   var origin = getOriginFromUrl(/** @type {string} */ (sender.url));
     54   if (!origin) {
     55     sendErrorResponse(GnubbyCodeTypes.BAD_REQUEST);
     56     return null;
     57   }
     58 
     59   if (!isValidEnrollRequest(request)) {
     60     sendErrorResponse(GnubbyCodeTypes.BAD_REQUEST);
     61     return null;
     62   }
     63 
     64   var signData = request['signData'];
     65   var enrollChallenges = request['enrollChallenges'];
     66   var logMsgUrl = request['logMsgUrl'];
     67   var timeoutMillis = Enroller.DEFAULT_TIMEOUT_MILLIS;
     68   if (request['timeout']) {
     69     // Request timeout is in seconds.
     70     timeoutMillis = request['timeout'] * 1000;
     71   }
     72 
     73   function findChallengeOfVersion(enrollChallenges, version) {
     74     for (var i = 0; i < enrollChallenges.length; i++) {
     75       if (enrollChallenges[i]['version'] == version) {
     76         return enrollChallenges[i];
     77       }
     78     }
     79     return null;
     80   }
     81 
     82   function sendSuccessResponse(u2fVersion, info, browserData) {
     83     var enrollChallenge = findChallengeOfVersion(enrollChallenges, u2fVersion);
     84     if (!enrollChallenge) {
     85       sendErrorResponse(GnubbyCodeTypes.UNKNOWN_ERROR);
     86       return;
     87     }
     88     var enrollUpdateData = {};
     89     enrollUpdateData['enrollData'] = info;
     90     // Echo the used challenge back in the reply.
     91     for (var k in enrollChallenge) {
     92       enrollUpdateData[k] = enrollChallenge[k];
     93     }
     94     if (u2fVersion == 'U2F_V2') {
     95       // For U2F_V2, the challenge sent to the gnubby is modified to be the
     96       // hash of the browser data. Include the browser data.
     97       enrollUpdateData['browserData'] = browserData;
     98     }
     99     var response = formatWebPageResponse(
    100         GnubbyMsgTypes.ENROLL_WEB_REPLY, GnubbyCodeTypes.OK, enrollUpdateData);
    101     sendResponseOnce(response);
    102   }
    103 
    104   function sendNotification(code) {
    105     console.log(UTIL_fmt('notification, code=' + code));
    106     // Can the callback handle progress updates? If so, send one.
    107     if (toleratesMultipleResponses) {
    108       var response = formatWebPageResponse(
    109           GnubbyMsgTypes.ENROLL_WEB_NOTIFICATION, code);
    110       if (request['requestId']) {
    111         response['requestId'] = request['requestId'];
    112       }
    113       sendResponse(response);
    114     }
    115   }
    116 
    117   var timer = new CountdownTimer(timeoutMillis);
    118   var enroller = new Enroller(factory, timer, origin, sendErrorResponse,
    119       sendSuccessResponse, sendNotification, sender.tlsChannelId, logMsgUrl);
    120   enroller.doEnroll(enrollChallenges, signData);
    121   return /** @type {Closeable} */ (enroller);
    122 }
    123 
    124 /**
    125  * Returns whether the request appears to be a valid enroll request.
    126  * @param {Object} request the request.
    127  * @return {boolean} whether the request appears valid.
    128  */
    129 function isValidEnrollRequest(request) {
    130   if (!request.hasOwnProperty('enrollChallenges'))
    131     return false;
    132   var enrollChallenges = request['enrollChallenges'];
    133   if (!enrollChallenges.length)
    134     return false;
    135   var seenVersions = {};
    136   for (var i = 0; i < enrollChallenges.length; i++) {
    137     var enrollChallenge = enrollChallenges[i];
    138     var version = enrollChallenge['version'];
    139     if (!version) {
    140       // Version is implicitly V1 if not specified.
    141       version = 'U2F_V1';
    142     }
    143     if (version != 'U2F_V1' && version != 'U2F_V2') {
    144       return false;
    145     }
    146     if (seenVersions[version]) {
    147       // Each version can appear at most once.
    148       return false;
    149     }
    150     seenVersions[version] = version;
    151     if (!enrollChallenge['appId']) {
    152       return false;
    153     }
    154     if (!enrollChallenge['challenge']) {
    155       // The challenge is required.
    156       return false;
    157     }
    158   }
    159   var signData = request['signData'];
    160   // An empty signData is ok, in the case the user is not already enrolled.
    161   if (signData && !isValidSignData(signData))
    162     return false;
    163   return true;
    164 }
    165 
    166 /**
    167  * Creates a new object to track enrolling with a gnubby.
    168  * @param {!EnrollHelperFactory} helperFactory factory to create an enroll
    169  *     helper.
    170  * @param {!Countdown} timer Timer for enroll request.
    171  * @param {string} origin The origin making the request.
    172  * @param {function(number)} errorCb Called upon enroll failure with an error
    173  *     code.
    174  * @param {function(string, string, (string|undefined))} successCb Called upon
    175  *     enroll success with the version of the succeeding gnubby, the enroll
    176  *     data, and optionally the browser data associated with the enrollment.
    177  * @param {(function(number)|undefined)} opt_progressCb Called with progress
    178  *     updates to the enroll request.
    179  * @param {string=} opt_tlsChannelId the TLS channel ID, if any, of the origin
    180  *     making the request.
    181  * @param {string=} opt_logMsgUrl The url to post log messages to.
    182  * @constructor
    183  */
    184 function Enroller(helperFactory, timer, origin, errorCb, successCb,
    185     opt_progressCb, opt_tlsChannelId, opt_logMsgUrl) {
    186   /** @private {Countdown} */
    187   this.timer_ = timer;
    188   /** @private {string} */
    189   this.origin_ = origin;
    190   /** @private {function(number)} */
    191   this.errorCb_ = errorCb;
    192   /** @private {function(string, string, (string|undefined))} */
    193   this.successCb_ = successCb;
    194   /** @private {(function(number)|undefined)} */
    195   this.progressCb_ = opt_progressCb;
    196   /** @private {string|undefined} */
    197   this.tlsChannelId_ = opt_tlsChannelId;
    198   /** @private {string|undefined} */
    199   this.logMsgUrl_ = opt_logMsgUrl;
    200 
    201   /** @private {boolean} */
    202   this.done_ = false;
    203   /** @private {number|undefined} */
    204   this.lastProgressUpdate_ = undefined;
    205 
    206   /** @private {Object.<string, string>} */
    207   this.browserData_ = {};
    208   /** @private {Array.<EnrollHelperChallenge>} */
    209   this.encodedEnrollChallenges_ = [];
    210   /** @private {Array.<SignHelperChallenge>} */
    211   this.encodedSignChallenges_ = [];
    212   // Allow http appIds for http origins. (Broken, but the caller deserves
    213   // what they get.)
    214   /** @private {boolean} */
    215   this.allowHttp_ = this.origin_ ? this.origin_.indexOf('http://') == 0 : false;
    216 
    217   /** @private {EnrollHelper} */
    218   this.helper_ = helperFactory.createHelper(timer,
    219       this.helperError_.bind(this), this.helperSuccess_.bind(this),
    220       this.helperProgress_.bind(this));
    221 }
    222 
    223 /**
    224  * Default timeout value in case the caller never provides a valid timeout.
    225  */
    226 Enroller.DEFAULT_TIMEOUT_MILLIS = 30 * 1000;
    227 
    228 /**
    229  * Performs an enroll request with the given enroll and sign challenges.
    230  * @param {Array.<Object>} enrollChallenges A set of enroll challenges
    231  * @param {Array.<Object>} signChallenges A set of sign challenges for existing
    232  *     enrollments for this user and appId
    233  */
    234 Enroller.prototype.doEnroll = function(enrollChallenges, signChallenges) {
    235   this.setEnrollChallenges_(enrollChallenges);
    236   this.setSignChallenges_(signChallenges);
    237 
    238   // Begin fetching/checking the app ids.
    239   var enrollAppIds = [];
    240   for (var i = 0; i < enrollChallenges.length; i++) {
    241     enrollAppIds.push(enrollChallenges[i]['appId']);
    242   }
    243   var self = this;
    244   this.checkAppIds_(enrollAppIds, signChallenges, function(result) {
    245     if (result) {
    246       self.helper_.doEnroll(self.encodedEnrollChallenges_,
    247           self.encodedSignChallenges_);
    248     } else {
    249       self.notifyError_(GnubbyCodeTypes.BAD_APP_ID);
    250     }
    251   });
    252 };
    253 
    254 /**
    255  * Encodes the enroll challenges for use by an enroll helper.
    256  * @param {Array.<Object>} enrollChallenges A set of enroll challenges
    257  * @return {Array.<EnrollHelperChallenge>} the encoded challenges.
    258  * @private
    259  */
    260 Enroller.encodeEnrollChallenges_ = function(enrollChallenges) {
    261   var encodedChallenges = [];
    262   for (var i = 0; i < enrollChallenges.length; i++) {
    263     var enrollChallenge = enrollChallenges[i];
    264     var encodedChallenge = {};
    265     var version;
    266     if (enrollChallenge['version']) {
    267       version = enrollChallenge['version'];
    268     } else {
    269       // Version is implicitly V1 if not specified.
    270       version = 'U2F_V1';
    271     }
    272     encodedChallenge['version'] = version;
    273     encodedChallenge['challenge'] = enrollChallenge['challenge'];
    274     encodedChallenge['appIdHash'] =
    275         B64_encode(sha256HashOfString(enrollChallenge['appId']));
    276     encodedChallenges.push(encodedChallenge);
    277   }
    278   return encodedChallenges;
    279 };
    280 
    281 /**
    282  * Sets this enroller's enroll challenges.
    283  * @param {Array.<Object>} enrollChallenges The enroll challenges.
    284  * @private
    285  */
    286 Enroller.prototype.setEnrollChallenges_ = function(enrollChallenges) {
    287   var challenges = [];
    288   for (var i = 0; i < enrollChallenges.length; i++) {
    289     var enrollChallenge = enrollChallenges[i];
    290     var version = enrollChallenge.version;
    291     if (!version) {
    292       // Version is implicitly V1 if not specified.
    293       version = 'U2F_V1';
    294     }
    295 
    296     if (version == 'U2F_V2') {
    297       var modifiedChallenge = {};
    298       for (var k in enrollChallenge) {
    299         modifiedChallenge[k] = enrollChallenge[k];
    300       }
    301       // V2 enroll responses contain signatures over a browser data object,
    302       // which we're constructing here. The browser data object contains, among
    303       // other things, the server challenge.
    304       var serverChallenge = enrollChallenge['challenge'];
    305       var browserData = makeEnrollBrowserData(
    306           serverChallenge, this.origin_, this.tlsChannelId_);
    307       // Replace the challenge with the hash of the browser data.
    308       modifiedChallenge['challenge'] =
    309           B64_encode(sha256HashOfString(browserData));
    310       this.browserData_[version] =
    311           B64_encode(UTIL_StringToBytes(browserData));
    312       challenges.push(modifiedChallenge);
    313     } else {
    314       challenges.push(enrollChallenge);
    315     }
    316   }
    317   // Store the encoded challenges for use by the enroll helper.
    318   this.encodedEnrollChallenges_ =
    319       Enroller.encodeEnrollChallenges_(challenges);
    320 };
    321 
    322 /**
    323  * Sets this enroller's sign data.
    324  * @param {Array=} signData the sign challenges to add.
    325  * @private
    326  */
    327 Enroller.prototype.setSignChallenges_ = function(signData) {
    328   this.encodedSignChallenges_ = [];
    329   if (signData) {
    330     for (var i = 0; i < signData.length; i++) {
    331       var incomingChallenge = signData[i];
    332       var serverChallenge = incomingChallenge['challenge'];
    333       var appId = incomingChallenge['appId'];
    334       var encodedKeyHandle = incomingChallenge['keyHandle'];
    335 
    336       var challenge = makeChallenge(serverChallenge, appId, encodedKeyHandle,
    337           incomingChallenge['version']);
    338 
    339       this.encodedSignChallenges_.push(challenge);
    340     }
    341   }
    342 };
    343 
    344 /**
    345  * Checks the app ids associated with this enroll request, and calls a callback
    346  * with the result of the check.
    347  * @param {!Array.<string>} enrollAppIds The app ids in the enroll challenge
    348  *     portion of the enroll request.
    349  * @param {SignData} signData The sign data associated with the request.
    350  * @param {function(boolean)} cb Called with the result of the check.
    351  * @private
    352  */
    353 Enroller.prototype.checkAppIds_ = function(enrollAppIds, signData, cb) {
    354   if (!enrollAppIds || !enrollAppIds.length) {
    355     // Defensive programming check: the enroll request is required to contain
    356     // its own app ids, so if there aren't any, reject the request.
    357     cb(false);
    358     return;
    359   }
    360 
    361   /** @private {Array.<string>} */
    362   this.distinctAppIds_ =
    363       UTIL_unionArrays(enrollAppIds, getDistinctAppIds(signData));
    364   /** @private {boolean} */
    365   this.anyInvalidAppIds_ = false;
    366   /** @private {boolean} */
    367   this.appIdFailureReported_ = false;
    368   /** @private {number} */
    369   this.fetchedAppIds_ = 0;
    370 
    371   for (var i = 0; i < this.distinctAppIds_.length; i++) {
    372     var appId = this.distinctAppIds_[i];
    373     if (appId == this.origin_) {
    374       // Trivially allowed.
    375       this.fetchedAppIds_++;
    376       if (this.fetchedAppIds_ == this.distinctAppIds_.length &&
    377           !this.anyInvalidAppIds_) {
    378         // Last app id was fetched, and they were all valid: we're done.
    379         // (Note that the case when anyInvalidAppIds_ is true doesn't need to
    380         // be handled here: the callback was already called with false at that
    381         // point, see fetchedAllowedOriginsForAppId_.)
    382         cb(true);
    383       }
    384     } else {
    385       var start = new Date();
    386       fetchAllowedOriginsForAppId(appId, this.allowHttp_,
    387           this.fetchedAllowedOriginsForAppId_.bind(this, appId, start, cb));
    388     }
    389   }
    390 };
    391 
    392 /**
    393  * Called with the result of an app id fetch.
    394  * @param {string} appId the app id that was fetched.
    395  * @param {Date} start the time the fetch request started.
    396  * @param {function(boolean)} cb Called with the result of the app id check.
    397  * @param {number} rc The HTTP response code for the app id fetch.
    398  * @param {!Array.<string>} allowedOrigins The origins allowed for this app id.
    399  * @private
    400  */
    401 Enroller.prototype.fetchedAllowedOriginsForAppId_ =
    402     function(appId, start, cb, rc, allowedOrigins) {
    403   var end = new Date();
    404   this.fetchedAppIds_++;
    405   logFetchAppIdResult(appId, end - start, allowedOrigins, this.logMsgUrl_);
    406   if (rc != 200 && !(rc >= 400 && rc < 500)) {
    407     if (this.timer_.expired()) {
    408       // Act as though the helper timed out.
    409       this.helperError_(DeviceStatusCodes.TIMEOUT_STATUS, false);
    410     } else {
    411       start = new Date();
    412       fetchAllowedOriginsForAppId(appId, this.allowHttp_,
    413           this.fetchedAllowedOriginsForAppId_.bind(this, appId, start, cb));
    414     }
    415     return;
    416   }
    417   if (!isValidAppIdForOrigin(appId, this.origin_, allowedOrigins)) {
    418     logInvalidOriginForAppId(this.origin_, appId, this.logMsgUrl_);
    419     this.anyInvalidAppIds_ = true;
    420     if (!this.appIdFailureReported_) {
    421       // Only the failure case can happen more than once, so only report
    422       // it the first time.
    423       this.appIdFailureReported_ = true;
    424       cb(false);
    425     }
    426   }
    427   if (this.fetchedAppIds_ == this.distinctAppIds_.length &&
    428       !this.anyInvalidAppIds_) {
    429     // Last app id was fetched, and they were all valid: we're done.
    430     cb(true);
    431   }
    432 };
    433 
    434 /** Closes this enroller. */
    435 Enroller.prototype.close = function() {
    436   if (this.helper_) this.helper_.close();
    437 };
    438 
    439 /**
    440  * Notifies the caller with the error code.
    441  * @param {number} code Error code
    442  * @private
    443  */
    444 Enroller.prototype.notifyError_ = function(code) {
    445   if (this.done_)
    446     return;
    447   this.close();
    448   this.done_ = true;
    449   this.errorCb_(code);
    450 };
    451 
    452 /**
    453  * Notifies the caller of success with the provided response data.
    454  * @param {string} u2fVersion Protocol version
    455  * @param {string} info Response data
    456  * @param {string|undefined} opt_browserData Browser data used
    457  * @private
    458  */
    459 Enroller.prototype.notifySuccess_ =
    460     function(u2fVersion, info, opt_browserData) {
    461   if (this.done_)
    462     return;
    463   this.close();
    464   this.done_ = true;
    465   this.successCb_(u2fVersion, info, opt_browserData);
    466 };
    467 
    468 /**
    469  * Notifies the caller of progress with the error code.
    470  * @param {number} code Status code
    471  * @private
    472  */
    473 Enroller.prototype.notifyProgress_ = function(code) {
    474   if (this.done_)
    475     return;
    476   if (code != this.lastProgressUpdate_) {
    477     this.lastProgressUpdate_ = code;
    478     // If there is no progress callback, treat it like an error and clean up.
    479     if (this.progressCb_) {
    480       this.progressCb_(code);
    481     } else {
    482       this.notifyError_(code);
    483     }
    484   }
    485 };
    486 
    487 /**
    488  * Maps an enroll helper's error code namespace to the page's error code
    489  * namespace.
    490  * @param {number} code Error code from DeviceStatusCodes namespace.
    491  * @param {boolean} anyGnubbies Whether any gnubbies were found.
    492  * @return {number} A GnubbyCodeTypes error code.
    493  * @private
    494  */
    495 Enroller.mapError_ = function(code, anyGnubbies) {
    496   var reportedError = GnubbyCodeTypes.UNKNOWN_ERROR;
    497   switch (code) {
    498     case DeviceStatusCodes.WRONG_DATA_STATUS:
    499       reportedError = anyGnubbies ? GnubbyCodeTypes.ALREADY_ENROLLED :
    500           GnubbyCodeTypes.NO_GNUBBIES;
    501       break;
    502 
    503     case DeviceStatusCodes.WAIT_TOUCH_STATUS:
    504       reportedError = GnubbyCodeTypes.WAIT_TOUCH;
    505       break;
    506 
    507     case DeviceStatusCodes.BUSY_STATUS:
    508       reportedError = GnubbyCodeTypes.BUSY;
    509       break;
    510   }
    511   return reportedError;
    512 };
    513 
    514 /**
    515  * Called by the helper upon error.
    516  * @param {number} code Error code
    517  * @param {boolean} anyGnubbies If any gnubbies were found
    518  * @private
    519  */
    520 Enroller.prototype.helperError_ = function(code, anyGnubbies) {
    521   var reportedError = Enroller.mapError_(code, anyGnubbies);
    522   console.log(UTIL_fmt('helper reported ' + code.toString(16) +
    523       ', returning ' + reportedError));
    524   this.notifyError_(reportedError);
    525 };
    526 
    527 /**
    528  * Called by helper upon success.
    529  * @param {string} u2fVersion gnubby version.
    530  * @param {string} info enroll data.
    531  * @private
    532  */
    533 Enroller.prototype.helperSuccess_ = function(u2fVersion, info) {
    534   console.log(UTIL_fmt('Gnubby enrollment succeeded!!!!!'));
    535 
    536   var browserData;
    537   if (u2fVersion == 'U2F_V2') {
    538     // For U2F_V2, the challenge sent to the gnubby is modified to be the hash
    539     // of the browser data. Include the browser data.
    540     browserData = this.browserData_[u2fVersion];
    541   }
    542 
    543   this.notifySuccess_(u2fVersion, info, browserData);
    544 };
    545 
    546 /**
    547  * Called by helper to notify progress.
    548  * @param {number} code Status code
    549  * @param {boolean} anyGnubbies If any gnubbies were found
    550  * @private
    551  */
    552 Enroller.prototype.helperProgress_ = function(code, anyGnubbies) {
    553   var reportedError = Enroller.mapError_(code, anyGnubbies);
    554   console.log(UTIL_fmt('helper notified ' + code.toString(16) +
    555       ', returning ' + reportedError));
    556   this.notifyProgress_(reportedError);
    557 };
    558