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