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 Implements an enroll helper using USB gnubbies.
      7  */
      8 'use strict';
      9 
     10 /**
     11  * @param {!GnubbyFactory} factory A factory for Gnubby instances
     12  * @param {!Countdown} timer A timer for enroll timeout
     13  * @param {function(number, boolean)} errorCb Called when an enroll request
     14  *     fails with an error code and whether any gnubbies were found.
     15  * @param {function(string, string)} successCb Called with the result of a
     16  *     successful enroll request, along with the version of the gnubby that
     17  *     provided it.
     18  * @param {(function(number, boolean)|undefined)} opt_progressCb Called with
     19  *     progress updates to the enroll request.
     20  * @param {string=} opt_logMsgUrl A URL to post log messages to.
     21  * @constructor
     22  * @implements {EnrollHelper}
     23  */
     24 function UsbEnrollHelper(factory, timer, errorCb, successCb, opt_progressCb,
     25     opt_logMsgUrl) {
     26   /** @private {!GnubbyFactory} */
     27   this.factory_ = factory;
     28   /** @private {!Countdown} */
     29   this.timer_ = timer;
     30   /** @private {function(number, boolean)} */
     31   this.errorCb_ = errorCb;
     32   /** @private {function(string, string)} */
     33   this.successCb_ = successCb;
     34   /** @private {(function(number, boolean)|undefined)} */
     35   this.progressCb_ = opt_progressCb;
     36   /** @private {string|undefined} */
     37   this.logMsgUrl_ = opt_logMsgUrl;
     38 
     39   /** @private {Array.<SignHelperChallenge>} */
     40   this.signChallenges_ = [];
     41   /** @private {boolean} */
     42   this.signChallengesFinal_ = false;
     43   /** @private {Array.<usbGnubby>} */
     44   this.waitingForTouchGnubbies_ = [];
     45 
     46   /** @private {boolean} */
     47   this.closed_ = false;
     48   /** @private {boolean} */
     49   this.notified_ = false;
     50   /** @private {number|undefined} */
     51   this.lastProgressUpdate_ = undefined;
     52   /** @private {boolean} */
     53   this.signerComplete_ = false;
     54   this.getSomeGnubbies_();
     55 }
     56 
     57 /**
     58  * Attempts to enroll using the provided data.
     59  * @param {Object} enrollChallenges a map of version string to enroll
     60  *     challenges.
     61  * @param {Array.<SignHelperChallenge>} signChallenges a list of sign
     62  *     challenges for already enrolled gnubbies, to prevent double-enrolling a
     63  *     device.
     64  */
     65 UsbEnrollHelper.prototype.doEnroll =
     66     function(enrollChallenges, signChallenges) {
     67   this.enrollChallenges = enrollChallenges;
     68   this.signChallengesFinal_ = true;
     69   if (this.signer_) {
     70     this.signer_.addEncodedChallenges(
     71         signChallenges, this.signChallengesFinal_);
     72   } else {
     73     this.signChallenges_ = signChallenges;
     74   }
     75 };
     76 
     77 /** Closes this helper. */
     78 UsbEnrollHelper.prototype.close = function() {
     79   this.closed_ = true;
     80   for (var i = 0; i < this.waitingForTouchGnubbies_.length; i++) {
     81     this.waitingForTouchGnubbies_[i].closeWhenIdle();
     82   }
     83   this.waitingForTouchGnubbies_ = [];
     84   if (this.signer_) {
     85     this.signer_.close();
     86     this.signer_ = null;
     87   }
     88 };
     89 
     90 /**
     91  * Enumerates gnubbies, and begins processing challenges upon enumeration if
     92  * any gnubbies are found.
     93  * @private
     94  */
     95 UsbEnrollHelper.prototype.getSomeGnubbies_ = function() {
     96   this.factory_.enumerate(this.enumerateCallback_.bind(this));
     97 };
     98 
     99 /**
    100  * Called with the result of enumerating gnubbies.
    101  * @param {number} rc the result of the enumerate.
    102  * @param {Array.<llGnubbyDeviceId>} indexes Device ids of enumerated gnubbies
    103  * @private
    104  */
    105 UsbEnrollHelper.prototype.enumerateCallback_ = function(rc, indexes) {
    106   if (rc) {
    107     // Enumerate failure is rare enough that it might be worth reporting
    108     // directly, rather than trying again.
    109     this.errorCb_(rc, false);
    110     return;
    111   }
    112   if (!indexes.length) {
    113     this.maybeReEnumerateGnubbies_();
    114     return;
    115   }
    116   if (this.timer_.expired()) {
    117     this.errorCb_(DeviceStatusCodes.TIMEOUT_STATUS, true);
    118     return;
    119   }
    120   this.gotSomeGnubbies_(indexes);
    121 };
    122 
    123 /**
    124  * If there's still time, re-enumerates devices and try with them. Otherwise
    125  * reports an error and, implicitly, stops the enroll operation.
    126  * @private
    127  */
    128 UsbEnrollHelper.prototype.maybeReEnumerateGnubbies_ = function() {
    129   var errorCode = DeviceStatusCodes.WRONG_DATA_STATUS;
    130   var anyGnubbies = false;
    131   // If there's still time and we're still going, retry enumerating.
    132   if (!this.closed_ && !this.timer_.expired()) {
    133     this.notifyProgress_(errorCode, anyGnubbies);
    134     var self = this;
    135     // Use a delayed re-enumerate to prevent hammering the system unnecessarily.
    136     window.setTimeout(function() {
    137       if (self.timer_.expired()) {
    138         self.notifyError_(errorCode, anyGnubbies);
    139       } else {
    140         self.getSomeGnubbies_();
    141       }
    142     }, 200);
    143   } else {
    144     this.notifyError_(errorCode, anyGnubbies);
    145   }
    146 };
    147 
    148 /**
    149  * Called with the result of enumerating gnubby indexes.
    150  * @param {Array.<llGnubbyDeviceId>} indexes Device ids of enumerated gnubbies
    151  * @private
    152  */
    153 UsbEnrollHelper.prototype.gotSomeGnubbies_ = function(indexes) {
    154   this.signer_ = new MultipleGnubbySigner(
    155       this.factory_,
    156       indexes,
    157       true /* forEnroll */,
    158       this.signerCompleted_.bind(this),
    159       this.signerFoundGnubby_.bind(this),
    160       this.timer_,
    161       this.logMsgUrl_);
    162   if (this.signChallengesFinal_) {
    163     this.signer_.addEncodedChallenges(
    164         this.signChallenges_, this.signChallengesFinal_);
    165     this.pendingSignChallenges_ = [];
    166   }
    167 };
    168 
    169 /**
    170  * Called when a MultipleGnubbySigner completes its sign request.
    171  * @param {boolean} anySucceeded whether any sign attempt completed
    172  *     successfully.
    173  * @param {number=} errorCode an error code from a failing gnubby, if one was
    174  *     found.
    175  * @private
    176  */
    177 UsbEnrollHelper.prototype.signerCompleted_ = function(anySucceeded, errorCode) {
    178   this.signerComplete_ = true;
    179   // The signer is not created unless some gnubbies were enumerated, so
    180   // anyGnubbies is mostly always true. The exception is when the last gnubby is
    181   // removed, handled shortly.
    182   var anyGnubbies = true;
    183   if (!anySucceeded) {
    184     if (errorCode == -llGnubby.GONE) {
    185       // If the last gnubby was removed, report as though no gnubbies were
    186       // found.
    187       this.maybeReEnumerateGnubbies_();
    188     } else {
    189       if (!errorCode) errorCode = DeviceStatusCodes.WRONG_DATA_STATUS;
    190       this.notifyError_(errorCode, anyGnubbies);
    191     }
    192   } else if (this.anyTimeout) {
    193     // Some previously succeeding gnubby timed out: return its error code.
    194     this.notifyError_(this.timeoutError, anyGnubbies);
    195   } else {
    196     // Do nothing: signerFoundGnubby will have been called with each succeeding
    197     // gnubby.
    198   }
    199 };
    200 
    201 /**
    202  * Called when a MultipleGnubbySigner finds a gnubby that can enroll.
    203  * @param {number} code Status code
    204  * @param {MultipleSignerResult} signResult Signature results
    205  * @private
    206  */
    207 UsbEnrollHelper.prototype.signerFoundGnubby_ = function(code, signResult) {
    208   var gnubby = signResult['gnubby'];
    209   this.waitingForTouchGnubbies_.push(gnubby);
    210   this.notifyProgress_(DeviceStatusCodes.WAIT_TOUCH_STATUS, true);
    211   if (code == DeviceStatusCodes.WRONG_DATA_STATUS) {
    212     if (signResult['challenge']) {
    213       // If the signer yielded a busy open, indicate waiting for touch
    214       // immediately, rather than attempting enroll. This allows the UI to
    215       // update, since a busy open is a potentially long operation.
    216       this.notifyError_(DeviceStatusCodes.WAIT_TOUCH_STATUS, true);
    217     } else {
    218       this.matchEnrollVersionToGnubby_(gnubby);
    219     }
    220   }
    221 };
    222 
    223 /**
    224  * Attempts to match the gnubby's U2F version with an appropriate enroll
    225  * challenge.
    226  * @param {usbGnubby} gnubby Gnubby instance
    227  * @private
    228  */
    229 UsbEnrollHelper.prototype.matchEnrollVersionToGnubby_ = function(gnubby) {
    230   if (!gnubby) {
    231     console.warn(UTIL_fmt('no gnubby, WTF?'));
    232   }
    233   gnubby.version(this.gnubbyVersioned_.bind(this, gnubby));
    234 };
    235 
    236 /**
    237  * Called with the result of a version command.
    238  * @param {usbGnubby} gnubby Gnubby instance
    239  * @param {number} rc result of version command.
    240  * @param {ArrayBuffer=} data version.
    241  * @private
    242  */
    243 UsbEnrollHelper.prototype.gnubbyVersioned_ = function(gnubby, rc, data) {
    244   if (rc) {
    245     this.removeWrongVersionGnubby_(gnubby);
    246     return;
    247   }
    248   var version = UTIL_BytesToString(new Uint8Array(data || null));
    249   this.tryEnroll_(gnubby, version);
    250 };
    251 
    252 /**
    253  * Drops the gnubby from the list of eligible gnubbies.
    254  * @param {usbGnubby} gnubby Gnubby instance
    255  * @private
    256  */
    257 UsbEnrollHelper.prototype.removeWaitingGnubby_ = function(gnubby) {
    258   gnubby.closeWhenIdle();
    259   var index = this.waitingForTouchGnubbies_.indexOf(gnubby);
    260   if (index >= 0) {
    261     this.waitingForTouchGnubbies_.splice(index, 1);
    262   }
    263 };
    264 
    265 /**
    266  * Drops the gnubby from the list of eligible gnubbies, as it has the wrong
    267  * version.
    268  * @param {usbGnubby} gnubby Gnubby instance
    269  * @private
    270  */
    271 UsbEnrollHelper.prototype.removeWrongVersionGnubby_ = function(gnubby) {
    272   this.removeWaitingGnubby_(gnubby);
    273   if (!this.waitingForTouchGnubbies_.length && this.signerComplete_) {
    274     // Whoops, this was the last gnubby: indicate there are none.
    275     this.notifyError_(DeviceStatusCodes.WRONG_DATA_STATUS, false);
    276   }
    277 };
    278 
    279 /**
    280  * Attempts enrolling a particular gnubby with a challenge of the appropriate
    281  * version.
    282  * @param {usbGnubby} gnubby Gnubby instance
    283  * @param {string} version Protocol version
    284  * @private
    285  */
    286 UsbEnrollHelper.prototype.tryEnroll_ = function(gnubby, version) {
    287   var challenge = this.getChallengeOfVersion_(version);
    288   if (!challenge) {
    289     this.removeWrongVersionGnubby_(gnubby);
    290     return;
    291   }
    292   var challengeChallenge = B64_decode(challenge['challenge']);
    293   var appIdHash = B64_decode(challenge['appIdHash']);
    294   gnubby.enroll(challengeChallenge, appIdHash,
    295       this.enrollCallback_.bind(this, gnubby, version));
    296 };
    297 
    298 /**
    299  * Finds the (first) challenge of the given version in this helper's challenges.
    300  * @param {string} version Protocol version
    301  * @return {Object} challenge, if found, or null if not.
    302  * @private
    303  */
    304 UsbEnrollHelper.prototype.getChallengeOfVersion_ = function(version) {
    305   for (var i = 0; i < this.enrollChallenges.length; i++) {
    306     if (this.enrollChallenges[i]['version'] == version) {
    307       return this.enrollChallenges[i];
    308     }
    309   }
    310   return null;
    311 };
    312 
    313 /**
    314  * Called with the result of an enroll request to a gnubby.
    315  * @param {usbGnubby} gnubby Gnubby instance
    316  * @param {string} version Protocol version
    317  * @param {number} code Status code
    318  * @param {ArrayBuffer=} infoArray Returned data
    319  * @private
    320  */
    321 UsbEnrollHelper.prototype.enrollCallback_ =
    322     function(gnubby, version, code, infoArray) {
    323   if (this.notified_) {
    324     // Enroll completed after previous success or failure. Disregard.
    325     return;
    326   }
    327   switch (code) {
    328     case -llGnubby.GONE:
    329         // Close this gnubby.
    330         this.removeWaitingGnubby_(gnubby);
    331         if (!this.waitingForTouchGnubbies_.length) {
    332           // Last enroll attempt is complete and last gnubby is gone: retry if
    333           // possible.
    334           this.maybeReEnumerateGnubbies_();
    335         }
    336       break;
    337 
    338     case DeviceStatusCodes.WAIT_TOUCH_STATUS:
    339     case DeviceStatusCodes.BUSY_STATUS:
    340     case DeviceStatusCodes.TIMEOUT_STATUS:
    341       if (this.timer_.expired()) {
    342         // Store any timeout error code, to be returned from the complete
    343         // callback if no other eligible gnubbies are found.
    344         this.anyTimeout = true;
    345         this.timeoutError = code;
    346         // Close this gnubby.
    347         this.removeWaitingGnubby_(gnubby);
    348         if (!this.waitingForTouchGnubbies_.length && !this.notified_) {
    349           // Last enroll attempt is complete: return this error.
    350           console.log(UTIL_fmt('timeout (' + code.toString(16) +
    351               ') enrolling'));
    352           this.notifyError_(code, true);
    353         }
    354       } else {
    355         // Notify caller of waiting for touch events.
    356         if (code == DeviceStatusCodes.WAIT_TOUCH_STATUS) {
    357           this.notifyProgress_(code, true);
    358         }
    359         window.setTimeout(this.tryEnroll_.bind(this, gnubby, version), 200);
    360       }
    361       break;
    362 
    363     case DeviceStatusCodes.OK_STATUS:
    364       var info = B64_encode(new Uint8Array(infoArray || []));
    365       this.notifySuccess_(version, info);
    366       break;
    367 
    368     default:
    369       console.log(UTIL_fmt('Failed to enroll gnubby: ' + code));
    370       this.notifyError_(code, true);
    371       break;
    372   }
    373 };
    374 
    375 /**
    376  * @param {number} code Status code
    377  * @param {boolean} anyGnubbies If any gnubbies were found
    378  * @private
    379  */
    380 UsbEnrollHelper.prototype.notifyError_ = function(code, anyGnubbies) {
    381   if (this.notified_ || this.closed_)
    382     return;
    383   this.notified_ = true;
    384   this.close();
    385   this.errorCb_(code, anyGnubbies);
    386 };
    387 
    388 /**
    389  * @param {string} version Protocol version
    390  * @param {string} info B64 encoded success data
    391  * @private
    392  */
    393 UsbEnrollHelper.prototype.notifySuccess_ = function(version, info) {
    394   if (this.notified_ || this.closed_)
    395     return;
    396   this.notified_ = true;
    397   this.close();
    398   this.successCb_(version, info);
    399 };
    400 
    401 /**
    402  * @param {number} code Status code
    403  * @param {boolean} anyGnubbies If any gnubbies were found
    404  * @private
    405  */
    406 UsbEnrollHelper.prototype.notifyProgress_ = function(code, anyGnubbies) {
    407   if (this.lastProgressUpdate_ == code || this.notified_ || this.closed_)
    408     return;
    409   this.lastProgressUpdate_ = code;
    410   if (this.progressCb_) this.progressCb_(code, anyGnubbies);
    411 };
    412 
    413 /**
    414  * @param {!GnubbyFactory} gnubbyFactory factory to create gnubbies.
    415  * @constructor
    416  * @implements {EnrollHelperFactory}
    417  */
    418 function UsbEnrollHelperFactory(gnubbyFactory) {
    419   /** @private {!GnubbyFactory} */
    420   this.gnubbyFactory_ = gnubbyFactory;
    421 }
    422 
    423 /**
    424  * @param {!Countdown} timer Timeout timer
    425  * @param {function(number, boolean)} errorCb Called when an enroll request
    426  *     fails with an error code and whether any gnubbies were found.
    427  * @param {function(string, string)} successCb Called with the result of a
    428  *     successful enroll request, along with the version of the gnubby that
    429  *     provided it.
    430  * @param {(function(number, boolean)|undefined)} opt_progressCb Called with
    431  *     progress updates to the enroll request.
    432  * @param {string=} opt_logMsgUrl A URL to post log messages to.
    433  * @return {UsbEnrollHelper} the newly created helper.
    434  */
    435 UsbEnrollHelperFactory.prototype.createHelper =
    436     function(timer, errorCb, successCb, opt_progressCb, opt_logMsgUrl) {
    437   var helper =
    438       new UsbEnrollHelper(this.gnubbyFactory_, timer, errorCb, successCb,
    439           opt_progressCb, opt_logMsgUrl);
    440   return helper;
    441 };
    442