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 a web enroll request.
     13  * @param {MessageSender} sender The sender of the message.
     14  * @param {Object} request The web page's enroll request.
     15  * @param {Function} sendResponse Called back with the result of the enroll.
     16  * @return {Closeable} A handler object to be closed when the browser channel
     17  *     closes.
     18  */
     19 function handleWebEnrollRequest(sender, request, sendResponse) {
     20   var sentResponse = false;
     21   var closeable = null;
     22 
     23   function sendErrorResponse(error) {
     24     var response = makeWebErrorResponse(request,
     25         mapErrorCodeToGnubbyCodeType(error.errorCode, false /* forSign */));
     26     sendResponseOnce(sentResponse, closeable, response, sendResponse);
     27   }
     28 
     29   function sendSuccessResponse(u2fVersion, info, browserData) {
     30     var enrollChallenges = request['enrollChallenges'];
     31     var enrollChallenge =
     32         findEnrollChallengeOfVersion(enrollChallenges, u2fVersion);
     33     if (!enrollChallenge) {
     34       sendErrorResponse(ErrorCodes.OTHER_ERROR);
     35       return;
     36     }
     37     var responseData =
     38         makeEnrollResponseData(enrollChallenge, u2fVersion,
     39             'enrollData', info, 'browserData', browserData);
     40     var response = makeWebSuccessResponse(request, responseData);
     41     sendResponseOnce(sentResponse, closeable, response, sendResponse);
     42   }
     43 
     44   var enroller =
     45       validateEnrollRequest(
     46           sender, request, 'enrollChallenges', 'signData',
     47           sendErrorResponse, sendSuccessResponse);
     48   if (enroller) {
     49     var registerRequests = request['enrollChallenges'];
     50     var signRequests = getSignRequestsFromEnrollRequest(request, 'signData');
     51     closeable = /** @type {Closeable} */ (enroller);
     52     enroller.doEnroll(registerRequests, signRequests, request['appId']);
     53   }
     54   return closeable;
     55 }
     56 
     57 /**
     58  * Handles a U2F enroll request.
     59  * @param {MessageSender} sender The sender of the message.
     60  * @param {Object} request The web page's enroll request.
     61  * @param {Function} sendResponse Called back with the result of the enroll.
     62  * @return {Closeable} A handler object to be closed when the browser channel
     63  *     closes.
     64  */
     65 function handleU2fEnrollRequest(sender, request, sendResponse) {
     66   var sentResponse = false;
     67   var closeable = null;
     68 
     69   function sendErrorResponse(error) {
     70     var response = makeU2fErrorResponse(request, error.errorCode,
     71         error.errorMessage);
     72     sendResponseOnce(sentResponse, closeable, response, sendResponse);
     73   }
     74 
     75   function sendSuccessResponse(u2fVersion, info, browserData) {
     76     var enrollChallenges = request['registerRequests'];
     77     var enrollChallenge =
     78         findEnrollChallengeOfVersion(enrollChallenges, u2fVersion);
     79     if (!enrollChallenge) {
     80       sendErrorResponse(ErrorCodes.OTHER_ERROR);
     81       return;
     82     }
     83     var responseData =
     84         makeEnrollResponseData(enrollChallenge, u2fVersion,
     85             'registrationData', info, 'clientData', browserData);
     86     var response = makeU2fSuccessResponse(request, responseData);
     87     sendResponseOnce(sentResponse, closeable, response, sendResponse);
     88   }
     89 
     90   var enroller =
     91       validateEnrollRequest(
     92           sender, request, 'registerRequests', 'signRequests',
     93           sendErrorResponse, sendSuccessResponse, 'registeredKeys');
     94   if (enroller) {
     95     var registerRequests = request['registerRequests'];
     96     var signRequests = getSignRequestsFromEnrollRequest(request,
     97         'signRequests', 'registeredKeys');
     98     closeable = /** @type {Closeable} */ (enroller);
     99     enroller.doEnroll(registerRequests, signRequests, request['appId']);
    100   }
    101   return closeable;
    102 }
    103 
    104 /**
    105  * Validates an enroll request using the given parameters.
    106  * @param {MessageSender} sender The sender of the message.
    107  * @param {Object} request The web page's enroll request.
    108  * @param {string} enrollChallengesName The name of the enroll challenges value
    109  *     in the request.
    110  * @param {string} signChallengesName The name of the sign challenges value in
    111  *     the request.
    112  * @param {function(U2fError)} errorCb Error callback.
    113  * @param {function(string, string, (string|undefined))} successCb Success
    114  *     callback.
    115  * @param {string=} opt_registeredKeysName The name of the registered keys
    116  *     value in the request.
    117  * @return {Enroller} Enroller object representing the request, if the request
    118  *     is valid, or null if the request is invalid.
    119  */
    120 function validateEnrollRequest(sender, request,
    121     enrollChallengesName, signChallengesName, errorCb, successCb,
    122     opt_registeredKeysName) {
    123   var origin = getOriginFromUrl(/** @type {string} */ (sender.url));
    124   if (!origin) {
    125     errorCb({errorCode: ErrorCodes.BAD_REQUEST});
    126     return null;
    127   }
    128 
    129   if (!isValidEnrollRequest(request, enrollChallengesName,
    130       signChallengesName, opt_registeredKeysName)) {
    131     errorCb({errorCode: ErrorCodes.BAD_REQUEST});
    132     return null;
    133   }
    134 
    135   var timer = createTimerForRequest(
    136       FACTORY_REGISTRY.getCountdownFactory(), request);
    137   var logMsgUrl = request['logMsgUrl'];
    138   var enroller = new Enroller(timer, origin, errorCb, successCb,
    139       sender.tlsChannelId, logMsgUrl);
    140   return enroller;
    141 }
    142 
    143 /**
    144  * Returns whether the request appears to be a valid enroll request.
    145  * @param {Object} request The request.
    146  * @param {string} enrollChallengesName The name of the enroll challenges value
    147  *     in the request.
    148  * @param {string} signChallengesName The name of the sign challenges value in
    149  *     the request.
    150  * @param {string=} opt_registeredKeysName The name of the registered keys
    151  *     value in the request.
    152  * @return {boolean} Whether the request appears valid.
    153  */
    154 function isValidEnrollRequest(request, enrollChallengesName,
    155     signChallengesName, opt_registeredKeysName) {
    156   if (!request.hasOwnProperty(enrollChallengesName))
    157     return false;
    158   var enrollChallenges = request[enrollChallengesName];
    159   if (!enrollChallenges.length)
    160     return false;
    161   var hasAppId = request.hasOwnProperty('appId');
    162   if (!isValidEnrollChallengeArray(enrollChallenges, !hasAppId))
    163     return false;
    164   var signChallenges = request[signChallengesName];
    165   // A missing sign challenge array is ok, in the case the user is not already
    166   // enrolled.
    167   if (signChallenges && !isValidSignChallengeArray(signChallenges, !hasAppId))
    168     return false;
    169   if (opt_registeredKeysName) {
    170     var registeredKeys = request[opt_registeredKeysName];
    171     if (registeredKeys &&
    172         !isValidRegisteredKeyArray(registeredKeys, !hasAppId)) {
    173       return false;
    174     }
    175   }
    176   return true;
    177 }
    178 
    179 /**
    180  * @typedef {{
    181  *   version: (string|undefined),
    182  *   challenge: string,
    183  *   appId: string
    184  * }}
    185  */
    186 var EnrollChallenge;
    187 
    188 /**
    189  * @param {Array.<EnrollChallenge>} enrollChallenges The enroll challenges to
    190  *     validate.
    191  * @param {boolean} appIdRequired Whether the appId property is required on
    192  *     each challenge.
    193  * @return {boolean} Whether the given array of challenges is a valid enroll
    194  *     challenges array.
    195  */
    196 function isValidEnrollChallengeArray(enrollChallenges, appIdRequired) {
    197   var seenVersions = {};
    198   for (var i = 0; i < enrollChallenges.length; i++) {
    199     var enrollChallenge = enrollChallenges[i];
    200     var version = enrollChallenge['version'];
    201     if (!version) {
    202       // Version is implicitly V1 if not specified.
    203       version = 'U2F_V1';
    204     }
    205     if (version != 'U2F_V1' && version != 'U2F_V2') {
    206       return false;
    207     }
    208     if (seenVersions[version]) {
    209       // Each version can appear at most once.
    210       return false;
    211     }
    212     seenVersions[version] = version;
    213     if (appIdRequired && !enrollChallenge['appId']) {
    214       return false;
    215     }
    216     if (!enrollChallenge['challenge']) {
    217       // The challenge is required.
    218       return false;
    219     }
    220   }
    221   return true;
    222 }
    223 
    224 /**
    225  * Finds the enroll challenge of the given version in the enroll challlenge
    226  * array.
    227  * @param {Array.<EnrollChallenge>} enrollChallenges The enroll challenges to
    228  *     search.
    229  * @param {string} version Version to search for.
    230  * @return {?EnrollChallenge} The enroll challenge with the given versions, or
    231  *     null if it isn't found.
    232  */
    233 function findEnrollChallengeOfVersion(enrollChallenges, version) {
    234   for (var i = 0; i < enrollChallenges.length; i++) {
    235     if (enrollChallenges[i]['version'] == version) {
    236       return enrollChallenges[i];
    237     }
    238   }
    239   return null;
    240 }
    241 
    242 /**
    243  * Makes a responseData object for the enroll request with the given parameters.
    244  * @param {EnrollChallenge} enrollChallenge The enroll challenge used to
    245  *     register.
    246  * @param {string} u2fVersion Version of gnubby that enrolled.
    247  * @param {string} enrollDataName The name of the enroll data key in the
    248  *     responseData object.
    249  * @param {string} enrollData The enroll data.
    250  * @param {string} browserDataName The name of the browser data key in the
    251  *     responseData object.
    252  * @param {string=} browserData The browser data, if available.
    253  * @return {Object} The responseData object.
    254  */
    255 function makeEnrollResponseData(enrollChallenge, u2fVersion, enrollDataName,
    256     enrollData, browserDataName, browserData) {
    257   var responseData = {};
    258   responseData[enrollDataName] = enrollData;
    259   // Echo the used challenge back in the reply.
    260   for (var k in enrollChallenge) {
    261     responseData[k] = enrollChallenge[k];
    262   }
    263   if (u2fVersion == 'U2F_V2') {
    264     // For U2F_V2, the challenge sent to the gnubby is modified to be the
    265     // hash of the browser data. Include the browser data.
    266     responseData[browserDataName] = browserData;
    267   }
    268   return responseData;
    269 }
    270 
    271 /**
    272  * Gets the expanded sign challenges from an enroll request, potentially by
    273  * modifying the request to contain a challenge value where one was omitted.
    274  * (For enrolling, the server isn't interested in the value of a signature,
    275  * only whether the presented key handle is already enrolled.)
    276  * @param {Object} request The request.
    277  * @param {string} signChallengesName The name of the sign challenges value in
    278  *     the request.
    279  * @param {string=} opt_registeredKeysName The name of the registered keys
    280  *     value in the request.
    281  * @return {Array.<SignChallenge>}
    282  */
    283 function getSignRequestsFromEnrollRequest(request, signChallengesName,
    284     opt_registeredKeysName) {
    285   var signChallenges;
    286   if (opt_registeredKeysName &&
    287       request.hasOwnProperty(opt_registeredKeysName)) {
    288     // Convert registered keys to sign challenges by adding a challenge value.
    289     signChallenges = request[opt_registeredKeysName];
    290     for (var i = 0; i < signChallenges.length; i++) {
    291       // The actual value doesn't matter, as long as it's a string.
    292       signChallenges[i]['challenge'] = '';
    293     }
    294   } else {
    295     signChallenges = request[signChallengesName];
    296   }
    297   return signChallenges;
    298 }
    299 
    300 /**
    301  * Creates a new object to track enrolling with a gnubby.
    302  * @param {!Countdown} timer Timer for enroll request.
    303  * @param {string} origin The origin making the request.
    304  * @param {function(U2fError)} errorCb Called upon enroll failure.
    305  * @param {function(string, string, (string|undefined))} successCb Called upon
    306  *     enroll success with the version of the succeeding gnubby, the enroll
    307  *     data, and optionally the browser data associated with the enrollment.
    308  * @param {string=} opt_tlsChannelId the TLS channel ID, if any, of the origin
    309  *     making the request.
    310  * @param {string=} opt_logMsgUrl The url to post log messages to.
    311  * @constructor
    312  */
    313 function Enroller(timer, origin, errorCb, successCb, opt_tlsChannelId,
    314     opt_logMsgUrl) {
    315   /** @private {Countdown} */
    316   this.timer_ = timer;
    317   /** @private {string} */
    318   this.origin_ = origin;
    319   /** @private {function(U2fError)} */
    320   this.errorCb_ = errorCb;
    321   /** @private {function(string, string, (string|undefined))} */
    322   this.successCb_ = successCb;
    323   /** @private {string|undefined} */
    324   this.tlsChannelId_ = opt_tlsChannelId;
    325   /** @private {string|undefined} */
    326   this.logMsgUrl_ = opt_logMsgUrl;
    327 
    328   /** @private {boolean} */
    329   this.done_ = false;
    330 
    331   /** @private {Object.<string, string>} */
    332   this.browserData_ = {};
    333   /** @private {Array.<EnrollHelperChallenge>} */
    334   this.encodedEnrollChallenges_ = [];
    335   /** @private {Array.<SignHelperChallenge>} */
    336   this.encodedSignChallenges_ = [];
    337   // Allow http appIds for http origins. (Broken, but the caller deserves
    338   // what they get.)
    339   /** @private {boolean} */
    340   this.allowHttp_ = this.origin_ ? this.origin_.indexOf('http://') == 0 : false;
    341   /** @private {Closeable} */
    342   this.handler_ = null;
    343 }
    344 
    345 /**
    346  * Default timeout value in case the caller never provides a valid timeout.
    347  */
    348 Enroller.DEFAULT_TIMEOUT_MILLIS = 30 * 1000;
    349 
    350 /**
    351  * Performs an enroll request with the given enroll and sign challenges.
    352  * @param {Array.<EnrollChallenge>} enrollChallenges A set of enroll challenges.
    353  * @param {Array.<SignChallenge>} signChallenges A set of sign challenges for
    354  *     existing enrollments for this user and appId.
    355  * @param {string=} opt_appId The app id for the entire request.
    356  */
    357 Enroller.prototype.doEnroll = function(enrollChallenges, signChallenges,
    358     opt_appId) {
    359   var encodedEnrollChallenges =
    360       this.encodeEnrollChallenges_(enrollChallenges, opt_appId);
    361   var encodedSignChallenges = encodeSignChallenges(signChallenges, opt_appId);
    362   var request = {
    363     type: 'enroll_helper_request',
    364     enrollChallenges: encodedEnrollChallenges,
    365     signData: encodedSignChallenges,
    366     logMsgUrl: this.logMsgUrl_
    367   };
    368   if (!this.timer_.expired()) {
    369     request.timeout = this.timer_.millisecondsUntilExpired() / 1000.0;
    370     request.timeoutSeconds = this.timer_.millisecondsUntilExpired() / 1000.0;
    371   }
    372 
    373   // Begin fetching/checking the app ids.
    374   var enrollAppIds = [];
    375   if (opt_appId) {
    376     enrollAppIds.push(opt_appId);
    377   }
    378   for (var i = 0; i < enrollChallenges.length; i++) {
    379     if (enrollChallenges[i].hasOwnProperty('appId')) {
    380       enrollAppIds.push(enrollChallenges[i]['appId']);
    381     }
    382   }
    383   // Sanity check
    384   if (!enrollAppIds.length) {
    385     console.warn(UTIL_fmt('empty enroll app ids?'));
    386     this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
    387     return;
    388   }
    389   var self = this;
    390   this.checkAppIds_(enrollAppIds, signChallenges, function(result) {
    391     if (result) {
    392       self.handler_ = FACTORY_REGISTRY.getRequestHelper().getHandler(request);
    393       if (self.handler_) {
    394         var helperComplete =
    395             /** @type {function(HelperReply)} */
    396             (self.helperComplete_.bind(self));
    397         self.handler_.run(helperComplete);
    398       } else {
    399         self.notifyError_({errorCode: ErrorCodes.OTHER_ERROR});
    400       }
    401     } else {
    402       self.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
    403     }
    404   });
    405 };
    406 
    407 /**
    408  * Encodes the enroll challenge as an enroll helper challenge.
    409  * @param {EnrollChallenge} enrollChallenge The enroll challenge to encode.
    410  * @param {string=} opt_appId The app id for the entire request.
    411  * @return {EnrollHelperChallenge} The encoded challenge.
    412  * @private
    413  */
    414 Enroller.encodeEnrollChallenge_ = function(enrollChallenge, opt_appId) {
    415   var encodedChallenge = {};
    416   var version;
    417   if (enrollChallenge['version']) {
    418     version = enrollChallenge['version'];
    419   } else {
    420     // Version is implicitly V1 if not specified.
    421     version = 'U2F_V1';
    422   }
    423   encodedChallenge['version'] = version;
    424   encodedChallenge['challengeHash'] = enrollChallenge['challenge'];
    425   var appId;
    426   if (enrollChallenge['appId']) {
    427     appId = enrollChallenge['appId'];
    428   } else {
    429     appId = opt_appId;
    430   }
    431   if (!appId) {
    432     // Sanity check. (Other code should fail if it's not set.)
    433     console.warn(UTIL_fmt('No appId?'));
    434   }
    435   encodedChallenge['appIdHash'] = B64_encode(sha256HashOfString(appId));
    436   return /** @type {EnrollHelperChallenge} */ (encodedChallenge);
    437 };
    438 
    439 /**
    440  * Encodes the given enroll challenges using this enroller's state.
    441  * @param {Array.<EnrollChallenge>} enrollChallenges The enroll challenges.
    442  * @param {string=} opt_appId The app id for the entire request.
    443  * @return {!Array.<EnrollHelperChallenge>} The encoded enroll challenges.
    444  * @private
    445  */
    446 Enroller.prototype.encodeEnrollChallenges_ = function(enrollChallenges,
    447     opt_appId) {
    448   var challenges = [];
    449   for (var i = 0; i < enrollChallenges.length; i++) {
    450     var enrollChallenge = enrollChallenges[i];
    451     var version = enrollChallenge.version;
    452     if (!version) {
    453       // Version is implicitly V1 if not specified.
    454       version = 'U2F_V1';
    455     }
    456 
    457     if (version == 'U2F_V2') {
    458       var modifiedChallenge = {};
    459       for (var k in enrollChallenge) {
    460         modifiedChallenge[k] = enrollChallenge[k];
    461       }
    462       // V2 enroll responses contain signatures over a browser data object,
    463       // which we're constructing here. The browser data object contains, among
    464       // other things, the server challenge.
    465       var serverChallenge = enrollChallenge['challenge'];
    466       var browserData = makeEnrollBrowserData(
    467           serverChallenge, this.origin_, this.tlsChannelId_);
    468       // Replace the challenge with the hash of the browser data.
    469       modifiedChallenge['challenge'] =
    470           B64_encode(sha256HashOfString(browserData));
    471       this.browserData_[version] =
    472           B64_encode(UTIL_StringToBytes(browserData));
    473       challenges.push(Enroller.encodeEnrollChallenge_(
    474           /** @type {EnrollChallenge} */ (modifiedChallenge), opt_appId));
    475     } else {
    476       challenges.push(
    477           Enroller.encodeEnrollChallenge_(enrollChallenge, opt_appId));
    478     }
    479   }
    480   return challenges;
    481 };
    482 
    483 /**
    484  * Checks the app ids associated with this enroll request, and calls a callback
    485  * with the result of the check.
    486  * @param {!Array.<string>} enrollAppIds The app ids in the enroll challenge
    487  *     portion of the enroll request.
    488  * @param {Array.<SignChallenge>} signChallenges The sign challenges associated
    489  *     with the request.
    490  * @param {function(boolean)} cb Called with the result of the check.
    491  * @private
    492  */
    493 Enroller.prototype.checkAppIds_ = function(enrollAppIds, signChallenges, cb) {
    494   var appIds =
    495       UTIL_unionArrays(enrollAppIds, getDistinctAppIds(signChallenges));
    496   FACTORY_REGISTRY.getOriginChecker().canClaimAppIds(this.origin_, appIds)
    497       .then(this.originChecked_.bind(this, appIds, cb));
    498 };
    499 
    500 /**
    501  * Called with the result of checking the origin. When the origin is allowed
    502  * to claim the app ids, begins checking whether the app ids also list the
    503  * origin.
    504  * @param {!Array.<string>} appIds The app ids.
    505  * @param {function(boolean)} cb Called with the result of the check.
    506  * @param {boolean} result Whether the origin could claim the app ids.
    507  * @private
    508  */
    509 Enroller.prototype.originChecked_ = function(appIds, cb, result) {
    510   if (!result) {
    511     this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
    512     return;
    513   }
    514   /** @private {!AppIdChecker} */
    515   this.appIdChecker_ = new AppIdChecker(FACTORY_REGISTRY.getTextFetcher(),
    516       this.timer_.clone(), this.origin_, appIds, this.allowHttp_,
    517       this.logMsgUrl_);
    518   this.appIdChecker_.doCheck().then(cb);
    519 };
    520 
    521 /** Closes this enroller. */
    522 Enroller.prototype.close = function() {
    523   if (this.appIdChecker_) {
    524     this.appIdChecker_.close();
    525   }
    526   if (this.handler_) {
    527     this.handler_.close();
    528     this.handler_ = null;
    529   }
    530 };
    531 
    532 /**
    533  * Notifies the caller with the error.
    534  * @param {U2fError} error Error.
    535  * @private
    536  */
    537 Enroller.prototype.notifyError_ = function(error) {
    538   if (this.done_)
    539     return;
    540   this.close();
    541   this.done_ = true;
    542   this.errorCb_(error);
    543 };
    544 
    545 /**
    546  * Notifies the caller of success with the provided response data.
    547  * @param {string} u2fVersion Protocol version
    548  * @param {string} info Response data
    549  * @param {string|undefined} opt_browserData Browser data used
    550  * @private
    551  */
    552 Enroller.prototype.notifySuccess_ =
    553     function(u2fVersion, info, opt_browserData) {
    554   if (this.done_)
    555     return;
    556   this.close();
    557   this.done_ = true;
    558   this.successCb_(u2fVersion, info, opt_browserData);
    559 };
    560 
    561 /**
    562  * Called by the helper upon completion.
    563  * @param {EnrollHelperReply} reply The result of the enroll request.
    564  * @private
    565  */
    566 Enroller.prototype.helperComplete_ = function(reply) {
    567   if (reply.code) {
    568     var reportedError = mapDeviceStatusCodeToU2fError(reply.code);
    569     console.log(UTIL_fmt('helper reported ' + reply.code.toString(16) +
    570         ', returning ' + reportedError.errorCode));
    571     this.notifyError_(reportedError);
    572   } else {
    573     console.log(UTIL_fmt('Gnubby enrollment succeeded!!!!!'));
    574     var browserData;
    575 
    576     if (reply.version == 'U2F_V2') {
    577       // For U2F_V2, the challenge sent to the gnubby is modified to be the hash
    578       // of the browser data. Include the browser data.
    579       browserData = this.browserData_[reply.version];
    580     }
    581 
    582     this.notifySuccess_(/** @type {string} */ (reply.version),
    583                         /** @type {string} */ (reply.enrollData),
    584                         browserData);
    585   }
    586 };
    587