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 A single gnubby signer wraps the process of opening a gnubby, 7 * signing each challenge in an array of challenges until a success condition 8 * is satisfied, and finally yielding the gnubby upon success. 9 */ 10 11 'use strict'; 12 13 /** 14 * Creates a new sign handler with a gnubby. This handler will perform a sign 15 * operation using each challenge in an array of challenges until its success 16 * condition is satisified, or an error or timeout occurs. The success condition 17 * is defined differently depending whether this signer is used for enrolling 18 * or for signing: 19 * 20 * For enroll, success is defined as each challenge yielding wrong data. This 21 * means this gnubby is not currently enrolled for any of the appIds in any 22 * challenge. 23 * 24 * For sign, success is defined as any challenge yielding ok or waiting for 25 * touch. 26 * 27 * At most one of the success or failure callbacks will be called, and it will 28 * be called at most once. Neither callback is guaranteed to be called: if 29 * a final set of challenges is never given to this gnubby, or if the gnubby 30 * stays busy, the signer has no way to know whether the set of challenges it's 31 * been given has succeeded or failed. 32 * The callback is called only when the signer reaches success or failure, i.e. 33 * when there is no need for this signer to continue trying new challenges. 34 * 35 * @param {!GnubbyFactory} factory Used to create and open the gnubby. 36 * @param {llGnubbyDeviceId} gnubbyIndex Which gnubby to open. 37 * @param {boolean} forEnroll Whether this signer is signing for an attempted 38 * enroll operation. 39 * @param {function(number)} errorCb Called when this signer fails, i.e. no 40 * further attempts can succeed. 41 * @param {function(usbGnubby, number, (SingleSignerResult|undefined))} 42 * successCb Called when this signer succeeds. 43 * @param {Countdown=} opt_timer An advisory timer, beyond whose expiration the 44 * signer will not attempt any new operations, assuming the caller is no 45 * longer interested in the outcome. 46 * @param {string=} opt_logMsgUrl A URL to post log messages to. 47 * @constructor 48 */ 49 function SingleGnubbySigner(factory, gnubbyIndex, forEnroll, errorCb, successCb, 50 opt_timer, opt_logMsgUrl) { 51 /** @private {GnubbyFactory} */ 52 this.factory_ = factory; 53 /** @private {llGnubbyDeviceId} */ 54 this.gnubbyIndex_ = gnubbyIndex; 55 /** @private {SingleGnubbySigner.State} */ 56 this.state_ = SingleGnubbySigner.State.INIT; 57 /** @private {boolean} */ 58 this.forEnroll_ = forEnroll; 59 /** @private {function(number)} */ 60 this.errorCb_ = errorCb; 61 /** @private {function(usbGnubby, number, (SingleSignerResult|undefined))} */ 62 this.successCb_ = successCb; 63 /** @private {Countdown|undefined} */ 64 this.timer_ = opt_timer; 65 /** @private {string|undefined} */ 66 this.logMsgUrl_ = opt_logMsgUrl; 67 68 /** @private {!Array.<!SignHelperChallenge>} */ 69 this.challenges_ = []; 70 /** @private {number} */ 71 this.challengeIndex_ = 0; 72 /** @private {boolean} */ 73 this.challengesFinal_ = false; 74 75 /** @private {!Array.<string>} */ 76 this.notForMe_ = []; 77 } 78 79 /** @enum {number} */ 80 SingleGnubbySigner.State = { 81 /** Initial state. */ 82 INIT: 0, 83 /** The signer is attempting to open a gnubby. */ 84 OPENING: 1, 85 /** The signer's gnubby opened, but is busy. */ 86 BUSY: 2, 87 /** The signer has an open gnubby, but no challenges to sign. */ 88 IDLE: 3, 89 /** The signer is currently signing a challenge. */ 90 SIGNING: 4, 91 /** The signer encountered an error. */ 92 ERROR: 5, 93 /** The signer got a successful outcome. */ 94 SUCCESS: 6, 95 /** The signer is closing its gnubby. */ 96 CLOSING: 7, 97 /** The signer is closed. */ 98 CLOSED: 8 99 }; 100 101 /** 102 * Attempts to open this signer's gnubby, if it's not already open. 103 * (This is implicitly done by addChallenges.) 104 */ 105 SingleGnubbySigner.prototype.open = function() { 106 if (this.state_ == SingleGnubbySigner.State.INIT) { 107 this.state_ = SingleGnubbySigner.State.OPENING; 108 this.factory_.openGnubby(this.gnubbyIndex_, 109 this.forEnroll_, 110 this.openCallback_.bind(this), 111 this.logMsgUrl_); 112 } 113 }; 114 115 /** 116 * Closes this signer's gnubby, if it's held. 117 */ 118 SingleGnubbySigner.prototype.close = function() { 119 if (!this.gnubby_) return; 120 this.state_ = SingleGnubbySigner.State.CLOSING; 121 this.gnubby_.closeWhenIdle(this.closed_.bind(this)); 122 }; 123 124 /** 125 * Called when this signer's gnubby is closed. 126 * @private 127 */ 128 SingleGnubbySigner.prototype.closed_ = function() { 129 this.gnubby_ = null; 130 this.state_ = SingleGnubbySigner.State.CLOSED; 131 }; 132 133 /** 134 * Adds challenges to the set of challenges being tried by this signer. 135 * If the signer is currently idle, begins signing the new challenges. 136 * 137 * @param {Array.<SignHelperChallenge>} challenges Sign challenges 138 * @param {boolean} finalChallenges True if there are no more challenges to add 139 * @return {boolean} Whether the challenges were accepted. 140 */ 141 SingleGnubbySigner.prototype.addChallenges = 142 function(challenges, finalChallenges) { 143 if (this.challengesFinal_) { 144 // Can't add new challenges once they're finalized. 145 return false; 146 } 147 148 if (challenges) { 149 console.log(this.gnubby_); 150 console.log(UTIL_fmt('adding ' + challenges.length + ' challenges')); 151 for (var i = 0; i < challenges.length; i++) { 152 this.challenges_.push(challenges[i]); 153 } 154 } 155 this.challengesFinal_ = finalChallenges; 156 157 switch (this.state_) { 158 case SingleGnubbySigner.State.INIT: 159 this.open(); 160 break; 161 case SingleGnubbySigner.State.OPENING: 162 // The open has already commenced, so accept the added challenges, but 163 // don't do anything. 164 break; 165 case SingleGnubbySigner.State.IDLE: 166 if (this.challengeIndex_ < challenges.length) { 167 // New challenges added: restart signing. 168 this.doSign_(this.challengeIndex_); 169 } else if (finalChallenges) { 170 // Finalized with no new challenges can happen when the caller rejects 171 // the appId for some challenge. 172 // If this signer is for enroll, the request must be rejected: this 173 // signer can't determine whether the gnubby is or is not enrolled for 174 // the origin. 175 // If this signer is for sign, the request must also be rejected: there 176 // are no new challenges to sign, and all previous ones did not yield 177 // success. 178 var self = this; 179 window.setTimeout(function() { 180 self.goToError_(DeviceStatusCodes.WRONG_DATA_STATUS); 181 }, 0); 182 } 183 break; 184 case SingleGnubbySigner.State.SIGNING: 185 // Already signing, so don't kick off a new sign, but accept the added 186 // challenges. 187 break; 188 default: 189 return false; 190 } 191 return true; 192 }; 193 194 /** 195 * How long to delay retrying a failed open. 196 */ 197 SingleGnubbySigner.OPEN_DELAY_MILLIS = 200; 198 199 /** 200 * @param {number} rc The result of the open operation. 201 * @param {usbGnubby=} gnubby The opened gnubby, if open was successful (or 202 * busy). 203 * @private 204 */ 205 SingleGnubbySigner.prototype.openCallback_ = function(rc, gnubby) { 206 if (this.state_ != SingleGnubbySigner.State.OPENING && 207 this.state_ != SingleGnubbySigner.State.BUSY) { 208 // Open completed after close, perhaps? Ignore. 209 return; 210 } 211 212 switch (rc) { 213 case DeviceStatusCodes.OK_STATUS: 214 if (!gnubby) { 215 console.warn(UTIL_fmt('open succeeded but gnubby is null, WTF?')); 216 } else { 217 this.gnubby_ = gnubby; 218 this.gnubby_.version(this.versionCallback_.bind(this)); 219 } 220 break; 221 case DeviceStatusCodes.BUSY_STATUS: 222 this.gnubby_ = gnubby; 223 this.openedBusy_ = true; 224 this.state_ = SingleGnubbySigner.State.BUSY; 225 // If there's still time, retry the open. 226 if (!this.timer_ || !this.timer_.expired()) { 227 var self = this; 228 window.setTimeout(function() { 229 if (self.gnubby_) { 230 self.factory_.openGnubby(self.gnubbyIndex_, 231 self.forEnroll_, 232 self.openCallback_.bind(self), 233 self.logMsgUrl_); 234 } 235 }, SingleGnubbySigner.OPEN_DELAY_MILLIS); 236 } else { 237 this.goToError_(DeviceStatusCodes.BUSY_STATUS); 238 } 239 break; 240 default: 241 // TODO: This won't be confused with success, but should it be 242 // part of the same namespace as the other error codes, which are 243 // always in DeviceStatusCodes.*? 244 this.goToError_(rc); 245 } 246 }; 247 248 /** 249 * Called with the result of a version command. 250 * @param {number} rc Result of version command. 251 * @param {ArrayBuffer=} opt_data Version. 252 * @private 253 */ 254 SingleGnubbySigner.prototype.versionCallback_ = function(rc, opt_data) { 255 if (rc) { 256 this.goToError_(rc); 257 return; 258 } 259 this.state_ = SingleGnubbySigner.State.IDLE; 260 this.version_ = UTIL_BytesToString(new Uint8Array(opt_data || [])); 261 this.doSign_(this.challengeIndex_); 262 }; 263 264 /** 265 * @param {number} challengeIndex Index of challenge to sign 266 * @private 267 */ 268 SingleGnubbySigner.prototype.doSign_ = function(challengeIndex) { 269 if (this.timer_ && this.timer_.expired()) { 270 // If the timer is expired, that means we never got a success or a touch 271 // required response: either always implies completion of this signer's 272 // state machine (see signCallback's cases for OK_STATUS and 273 // WAIT_TOUCH_STATUS.) We could have gotten wrong data on a partial set of 274 // challenges, but this means we don't yet know the final outcome. In any 275 // event, we don't yet know the final outcome: return busy. 276 this.goToError_(DeviceStatusCodes.BUSY_STATUS); 277 return; 278 } 279 280 this.state_ = SingleGnubbySigner.State.SIGNING; 281 282 if (challengeIndex >= this.challenges_.length) { 283 this.signCallback_(challengeIndex, DeviceStatusCodes.WRONG_DATA_STATUS); 284 return; 285 } 286 287 var challenge = this.challenges_[challengeIndex]; 288 var challengeHash = challenge.challengeHash; 289 var appIdHash = challenge.appIdHash; 290 var keyHandle = challenge.keyHandle; 291 if (this.notForMe_.indexOf(keyHandle) != -1) { 292 // Cache hit: return wrong data again. 293 this.signCallback_(challengeIndex, DeviceStatusCodes.WRONG_DATA_STATUS); 294 } else if (challenge.version && challenge.version != this.version_) { 295 // Sign challenge for a different version of gnubby: return wrong data. 296 this.signCallback_(challengeIndex, DeviceStatusCodes.WRONG_DATA_STATUS); 297 } else { 298 var nowink = this.forEnroll_; 299 this.gnubby_.sign(challengeHash, appIdHash, keyHandle, 300 this.signCallback_.bind(this, challengeIndex), 301 nowink); 302 } 303 }; 304 305 /** 306 * Called with the result of a single sign operation. 307 * @param {number} challengeIndex the index of the challenge just attempted 308 * @param {number} code the result of the sign operation 309 * @param {ArrayBuffer=} opt_info Optional result data 310 * @private 311 */ 312 SingleGnubbySigner.prototype.signCallback_ = 313 function(challengeIndex, code, opt_info) { 314 console.log(UTIL_fmt('gnubby ' + JSON.stringify(this.gnubbyIndex_) + 315 ', challenge ' + challengeIndex + ' yielded ' + code.toString(16))); 316 if (this.state_ != SingleGnubbySigner.State.SIGNING) { 317 console.log(UTIL_fmt('already done!')); 318 // We're done, the caller's no longer interested. 319 return; 320 } 321 322 // Cache wrong data result, re-asking the gnubby to sign it won't produce 323 // different results. 324 if (code == DeviceStatusCodes.WRONG_DATA_STATUS) { 325 if (challengeIndex < this.challenges_.length) { 326 var challenge = this.challenges_[challengeIndex]; 327 if (this.notForMe_.indexOf(challenge.keyHandle) == -1) { 328 this.notForMe_.push(challenge.keyHandle); 329 } 330 } 331 } 332 333 switch (code) { 334 case DeviceStatusCodes.GONE_STATUS: 335 this.goToError_(code); 336 break; 337 338 case DeviceStatusCodes.TIMEOUT_STATUS: 339 // TODO: On a TIMEOUT_STATUS, sync first, then retry. 340 case DeviceStatusCodes.BUSY_STATUS: 341 this.doSign_(this.challengeIndex_); 342 break; 343 344 case DeviceStatusCodes.OK_STATUS: 345 if (this.forEnroll_) { 346 this.goToError_(code); 347 } else { 348 this.goToSuccess_(code, this.challenges_[challengeIndex], opt_info); 349 } 350 break; 351 352 case DeviceStatusCodes.WAIT_TOUCH_STATUS: 353 if (this.forEnroll_) { 354 this.goToError_(code); 355 } else { 356 this.goToSuccess_(code, this.challenges_[challengeIndex]); 357 } 358 break; 359 360 case DeviceStatusCodes.WRONG_DATA_STATUS: 361 if (this.challengeIndex_ < this.challenges_.length - 1) { 362 this.doSign_(++this.challengeIndex_); 363 } else if (!this.challengesFinal_) { 364 this.state_ = SingleGnubbySigner.State.IDLE; 365 } else if (this.forEnroll_) { 366 // Signal the caller whether the open was busy, because it may take 367 // an unusually long time when opened for enroll. Use an empty 368 // "challenge" as the signal for a busy open. 369 var challenge = undefined; 370 if (this.openedBusy) { 371 challenge = { appIdHash: '', challengeHash: '', keyHandle: '' }; 372 } 373 this.goToSuccess_(code, challenge); 374 } else { 375 this.goToError_(code); 376 } 377 break; 378 379 default: 380 if (this.forEnroll_) { 381 this.goToError_(code); 382 } else if (this.challengeIndex_ < this.challenges_.length - 1) { 383 this.doSign_(++this.challengeIndex_); 384 } else if (!this.challengesFinal_) { 385 // Increment the challenge index, as this one isn't useful any longer, 386 // but a subsequent challenge may appear, and it might be useful. 387 this.challengeIndex_++; 388 this.state_ = SingleGnubbySigner.State.IDLE; 389 } else { 390 this.goToError_(code); 391 } 392 } 393 }; 394 395 /** 396 * Switches to the error state, and notifies caller. 397 * @param {number} code Error code 398 * @private 399 */ 400 SingleGnubbySigner.prototype.goToError_ = function(code) { 401 this.state_ = SingleGnubbySigner.State.ERROR; 402 console.log(UTIL_fmt('failed (' + code.toString(16) + ')')); 403 this.errorCb_(code); 404 // Since this gnubby can no longer produce a useful result, go ahead and 405 // close it. 406 this.close(); 407 }; 408 409 /** 410 * Switches to the success state, and notifies caller. 411 * @param {number} code Status code 412 * @param {SignHelperChallenge=} opt_challenge The challenge signed 413 * @param {ArrayBuffer=} opt_info Optional result data 414 * @private 415 */ 416 SingleGnubbySigner.prototype.goToSuccess_ = 417 function(code, opt_challenge, opt_info) { 418 this.state_ = SingleGnubbySigner.State.SUCCESS; 419 console.log(UTIL_fmt('success (' + code.toString(16) + ')')); 420 if (opt_challenge || opt_info) { 421 var singleSignerResult = {}; 422 if (opt_challenge) { 423 singleSignerResult['challenge'] = opt_challenge; 424 } 425 if (opt_info) { 426 singleSignerResult['info'] = opt_info; 427 } 428 } 429 this.successCb_(this.gnubby_, code, singleSignerResult); 430 // this.gnubby_ is now owned by successCb. 431 this.gnubby_ = null; 432 }; 433