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 multiple gnubby signer wraps the process of opening a number 7 * of gnubbies, signing each challenge in an array of challenges until a 8 * success condition is satisfied, and yielding each succeeding gnubby. 9 * 10 */ 11 'use strict'; 12 13 /** 14 * @typedef {{ 15 * code: number, 16 * gnubbyId: GnubbyDeviceId, 17 * challenge: (SignHelperChallenge|undefined), 18 * info: (ArrayBuffer|undefined) 19 * }} 20 */ 21 var MultipleSignerResult; 22 23 /** 24 * Creates a new sign handler that manages signing with all the available 25 * gnubbies. 26 * @param {boolean} forEnroll Whether this signer is signing for an attempted 27 * enroll operation. 28 * @param {function(boolean)} allCompleteCb Called when this signer completes 29 * sign attempts, i.e. no further results will be produced. The parameter 30 * indicates whether any gnubbies are present that have not yet produced a 31 * final result. 32 * @param {function(MultipleSignerResult, boolean)} gnubbyCompleteCb 33 * Called with each gnubby/challenge that yields a final result, along with 34 * whether this signer expects to produce more results. The boolean is a 35 * hint rather than a promise: it's possible for this signer to produce 36 * further results after saying it doesn't expect more, or to fail to 37 * produce further results after saying it does. 38 * @param {number} timeoutMillis A timeout value, beyond whose expiration the 39 * signer will not attempt any new operations, assuming the caller is no 40 * longer interested in the outcome. 41 * @param {string=} opt_logMsgUrl A URL to post log messages to. 42 * @constructor 43 */ 44 function MultipleGnubbySigner(forEnroll, allCompleteCb, gnubbyCompleteCb, 45 timeoutMillis, opt_logMsgUrl) { 46 /** @private {boolean} */ 47 this.forEnroll_ = forEnroll; 48 /** @private {function(boolean)} */ 49 this.allCompleteCb_ = allCompleteCb; 50 /** @private {function(MultipleSignerResult, boolean)} */ 51 this.gnubbyCompleteCb_ = gnubbyCompleteCb; 52 /** @private {string|undefined} */ 53 this.logMsgUrl_ = opt_logMsgUrl; 54 55 /** @private {Array.<SignHelperChallenge>} */ 56 this.challenges_ = []; 57 /** @private {boolean} */ 58 this.challengesSet_ = false; 59 /** @private {boolean} */ 60 this.complete_ = false; 61 /** @private {number} */ 62 this.numComplete_ = 0; 63 /** @private {!Object.<string, GnubbyTracker>} */ 64 this.gnubbies_ = {}; 65 /** @private {Countdown} */ 66 this.timer_ = DEVICE_FACTORY_REGISTRY.getCountdownFactory() 67 .createTimer(timeoutMillis); 68 /** @private {Countdown} */ 69 this.reenumerateTimer_ = DEVICE_FACTORY_REGISTRY.getCountdownFactory() 70 .createTimer(timeoutMillis); 71 } 72 73 /** 74 * @typedef {{ 75 * index: string, 76 * signer: SingleGnubbySigner, 77 * stillGoing: boolean, 78 * errorStatus: number 79 * }} 80 */ 81 var GnubbyTracker; 82 83 /** 84 * Closes this signer's gnubbies, if any are open. 85 */ 86 MultipleGnubbySigner.prototype.close = function() { 87 for (var k in this.gnubbies_) { 88 this.gnubbies_[k].signer.close(); 89 } 90 this.reenumerateTimer_.clearTimeout(); 91 this.timer_.clearTimeout(); 92 if (this.reenumerateIntervalTimer_) { 93 this.reenumerateIntervalTimer_.clearTimeout(); 94 } 95 }; 96 97 /** 98 * Begins signing the given challenges. 99 * @param {Array.<SignHelperChallenge>} challenges The challenges to sign. 100 * @return {boolean} whether the challenges were successfully added. 101 */ 102 MultipleGnubbySigner.prototype.doSign = function(challenges) { 103 if (this.challengesSet_) { 104 // Can't add new challenges once they're finalized. 105 return false; 106 } 107 108 if (challenges) { 109 for (var i = 0; i < challenges.length; i++) { 110 var decodedChallenge = {}; 111 var challenge = challenges[i]; 112 decodedChallenge['challengeHash'] = 113 B64_decode(challenge['challengeHash']); 114 decodedChallenge['appIdHash'] = B64_decode(challenge['appIdHash']); 115 decodedChallenge['keyHandle'] = B64_decode(challenge['keyHandle']); 116 if (challenge['version']) { 117 decodedChallenge['version'] = challenge['version']; 118 } 119 this.challenges_.push(decodedChallenge); 120 } 121 } 122 this.challengesSet_ = true; 123 this.enumerateGnubbies_(); 124 return true; 125 }; 126 127 /** 128 * Signals this signer to rescan for gnubbies. Useful when the caller has 129 * knowledge that the last device has been removed, and can notify this class 130 * before it will discover it on its own. 131 */ 132 MultipleGnubbySigner.prototype.reScanDevices = function() { 133 if (this.reenumerateIntervalTimer_) { 134 this.reenumerateIntervalTimer_.clearTimeout(); 135 } 136 this.maybeReEnumerateGnubbies_(true); 137 }; 138 139 /** 140 * Enumerates gnubbies. 141 * @private 142 */ 143 MultipleGnubbySigner.prototype.enumerateGnubbies_ = function() { 144 DEVICE_FACTORY_REGISTRY.getGnubbyFactory().enumerate( 145 this.enumerateCallback_.bind(this)); 146 }; 147 148 /** 149 * Called with the result of enumerating gnubbies. 150 * @param {number} rc The return code from enumerating. 151 * @param {Array.<GnubbyDeviceId>} ids The gnubbies enumerated. 152 * @private 153 */ 154 MultipleGnubbySigner.prototype.enumerateCallback_ = function(rc, ids) { 155 if (this.complete_) { 156 return; 157 } 158 if (rc || !ids || !ids.length) { 159 this.maybeReEnumerateGnubbies_(true); 160 return; 161 } 162 for (var i = 0; i < ids.length; i++) { 163 this.addGnubby_(ids[i]); 164 } 165 this.maybeReEnumerateGnubbies_(false); 166 }; 167 168 /** 169 * How frequently to reenumerate gnubbies when none are found, in milliseconds. 170 * @const 171 */ 172 MultipleGnubbySigner.ACTIVE_REENUMERATE_INTERVAL_MILLIS = 200; 173 174 /** 175 * How frequently to reenumerate gnubbies when some are found, in milliseconds. 176 * @const 177 */ 178 MultipleGnubbySigner.PASSIVE_REENUMERATE_INTERVAL_MILLIS = 3000; 179 180 /** 181 * Reenumerates gnubbies if there's still time. 182 * @param {boolean} activeScan Whether to poll more aggressively, e.g. if 183 * there are no devices present. 184 * @private 185 */ 186 MultipleGnubbySigner.prototype.maybeReEnumerateGnubbies_ = 187 function(activeScan) { 188 if (this.reenumerateTimer_.expired()) { 189 // If the timer is expired, call timeout_ if there aren't any still-running 190 // gnubbies. (If there are some still running, the last will call timeout_ 191 // itself.) 192 if (!this.anyPending_()) { 193 this.timeout_(false); 194 } 195 return; 196 } 197 // Reenumerate more aggressively if there are no gnubbies present than if 198 // there are any. 199 var reenumerateTimeoutMillis; 200 if (activeScan) { 201 reenumerateTimeoutMillis = 202 MultipleGnubbySigner.ACTIVE_REENUMERATE_INTERVAL_MILLIS; 203 } else { 204 reenumerateTimeoutMillis = 205 MultipleGnubbySigner.PASSIVE_REENUMERATE_INTERVAL_MILLIS; 206 } 207 if (reenumerateTimeoutMillis > 208 this.reenumerateTimer_.millisecondsUntilExpired()) { 209 reenumerateTimeoutMillis = 210 this.reenumerateTimer_.millisecondsUntilExpired(); 211 } 212 /** @private {Countdown} */ 213 this.reenumerateIntervalTimer_ = 214 DEVICE_FACTORY_REGISTRY.getCountdownFactory().createTimer( 215 reenumerateTimeoutMillis, this.enumerateGnubbies_.bind(this)); 216 }; 217 218 /** 219 * Adds a new gnubby to this signer's list of gnubbies. (Only possible while 220 * this signer is still signing: without this restriction, the completed 221 * callback could be called more than once, in violation of its contract.) 222 * If this signer has challenges to sign, begins signing on the new gnubby with 223 * them. 224 * @param {GnubbyDeviceId} gnubbyId The id of the gnubby to add. 225 * @return {boolean} Whether the gnubby was added successfully. 226 * @private 227 */ 228 MultipleGnubbySigner.prototype.addGnubby_ = function(gnubbyId) { 229 var index = JSON.stringify(gnubbyId); 230 if (this.gnubbies_.hasOwnProperty(index)) { 231 // Can't add the same gnubby twice. 232 return false; 233 } 234 var tracker = { 235 index: index, 236 errorStatus: 0, 237 stillGoing: false, 238 signer: null 239 }; 240 tracker.signer = new SingleGnubbySigner( 241 gnubbyId, 242 this.forEnroll_, 243 this.signCompletedCallback_.bind(this, tracker), 244 this.timer_.clone(), 245 this.logMsgUrl_); 246 this.gnubbies_[index] = tracker; 247 this.gnubbies_[index].stillGoing = 248 tracker.signer.doSign(this.challenges_); 249 if (!this.gnubbies_[index].errorStatus) { 250 this.gnubbies_[index].errorStatus = 0; 251 } 252 return true; 253 }; 254 255 /** 256 * Called by a SingleGnubbySigner upon completion. 257 * @param {GnubbyTracker} tracker The tracker object of the gnubby whose result 258 * this is. 259 * @param {SingleSignerResult} result The result of the sign operation. 260 * @private 261 */ 262 MultipleGnubbySigner.prototype.signCompletedCallback_ = 263 function(tracker, result) { 264 console.log( 265 UTIL_fmt((result.code ? 'failure.' : 'success!') + 266 ' gnubby ' + tracker.index + 267 ' got code ' + result.code.toString(16))); 268 if (!tracker.stillGoing) { 269 console.log(UTIL_fmt('gnubby ' + tracker.index + ' no longer running!')); 270 // Shouldn't ever happen? Disregard. 271 return; 272 } 273 tracker.stillGoing = false; 274 tracker.errorStatus = result.code; 275 var moreExpected = this.tallyCompletedGnubby_(); 276 switch (result.code) { 277 case DeviceStatusCodes.GONE_STATUS: 278 // Squelch removed gnubbies: the caller can't act on them. But if this 279 // was the last one, speed up reenumerating. 280 if (!moreExpected) { 281 this.maybeReEnumerateGnubbies_(true); 282 } 283 break; 284 285 default: 286 // Report any other results directly to the caller. 287 this.notifyGnubbyComplete_(tracker, result, moreExpected); 288 break; 289 } 290 if (!moreExpected && this.timer_.expired()) { 291 this.timeout_(false); 292 } 293 }; 294 295 /** 296 * Counts another gnubby has having completed, and returns whether more results 297 * are expected. 298 * @return {boolean} Whether more gnubbies are still running. 299 * @private 300 */ 301 MultipleGnubbySigner.prototype.tallyCompletedGnubby_ = function() { 302 this.numComplete_++; 303 return this.anyPending_(); 304 }; 305 306 /** 307 * @return {boolean} Whether more gnubbies are still running. 308 * @private 309 */ 310 MultipleGnubbySigner.prototype.anyPending_ = function() { 311 return this.numComplete_ < Object.keys(this.gnubbies_).length; 312 }; 313 314 /** 315 * Called upon timeout. 316 * @param {boolean} anyPending Whether any gnubbies are awaiting results. 317 * @private 318 */ 319 MultipleGnubbySigner.prototype.timeout_ = function(anyPending) { 320 if (this.complete_) return; 321 this.complete_ = true; 322 // Defer notifying the caller that all are complete, in case the caller is 323 // doing work in response to a gnubbyFound callback and has an inconsistent 324 // view of the state of this signer. 325 var self = this; 326 window.setTimeout(function() { 327 self.allCompleteCb_(anyPending); 328 }, 0); 329 }; 330 331 /** 332 * @param {GnubbyTracker} tracker The tracker object of the gnubby whose result 333 * this is. 334 * @param {SingleSignerResult} result Result object. 335 * @param {boolean} moreExpected Whether more gnubbies may still produce an 336 * outcome. 337 * @private 338 */ 339 MultipleGnubbySigner.prototype.notifyGnubbyComplete_ = 340 function(tracker, result, moreExpected) { 341 console.log(UTIL_fmt('gnubby ' + tracker.index + ' complete (' + 342 result.code.toString(16) + ')')); 343 var signResult = { 344 'code': result.code, 345 'gnubby': result.gnubby, 346 'gnubbyId': tracker.signer.getDeviceId() 347 }; 348 if (result['challenge']) 349 signResult['challenge'] = result['challenge']; 350 if (result['info']) 351 signResult['info'] = result['info']; 352 this.gnubbyCompleteCb_(signResult, moreExpected); 353 }; 354