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