// Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview A single gnubby signer wraps the process of opening a gnubby, * signing each challenge in an array of challenges until a success condition * is satisfied, and finally yielding the gnubby upon success. */ 'use strict'; /** * @typedef {{ * code: number, * gnubby: (usbGnubby|undefined), * challenge: (SignHelperChallenge|undefined), * info: (ArrayBuffer|undefined) * }} */ var SingleSignerResult; /** * Creates a new sign handler with a gnubby. This handler will perform a sign * operation using each challenge in an array of challenges until its success * condition is satisified, or an error or timeout occurs. The success condition * is defined differently depending whether this signer is used for enrolling * or for signing: * * For enroll, success is defined as each challenge yielding wrong data. This * means this gnubby is not currently enrolled for any of the appIds in any * challenge. * * For sign, success is defined as any challenge yielding ok. * * At most one of the success or failure callbacks will be called, and it will * be called at most once. Neither callback is guaranteed to be called: if * a final set of challenges is never given to this gnubby, or if the gnubby * stays busy, the signer has no way to know whether the set of challenges it's * been given has succeeded or failed. * The callback is called only when the signer reaches success or failure, i.e. * when there is no need for this signer to continue trying new challenges. * * @param {!GnubbyFactory} factory Used to create and open the gnubby. * @param {llGnubbyDeviceId} gnubbyId Which gnubby to open. * @param {boolean} forEnroll Whether this signer is signing for an attempted * enroll operation. * @param {function(SingleSignerResult)} * completeCb Called when this signer completes, i.e. no further results are * possible. * @param {Countdown} timer An advisory timer, beyond whose expiration the * signer will not attempt any new operations, assuming the caller is no * longer interested in the outcome. * @param {string=} opt_logMsgUrl A URL to post log messages to. * @constructor */ function SingleGnubbySigner(factory, gnubbyId, forEnroll, completeCb, timer, opt_logMsgUrl) { /** @private {GnubbyFactory} */ this.factory_ = factory; /** @private {llGnubbyDeviceId} */ this.gnubbyId_ = gnubbyId; /** @private {SingleGnubbySigner.State} */ this.state_ = SingleGnubbySigner.State.INIT; /** @private {boolean} */ this.forEnroll_ = forEnroll; /** @private {function(SingleSignerResult)} */ this.completeCb_ = completeCb; /** @private {Countdown} */ this.timer_ = timer; /** @private {string|undefined} */ this.logMsgUrl_ = opt_logMsgUrl; /** @private {!Array.} */ this.challenges_ = []; /** @private {number} */ this.challengeIndex_ = 0; /** @private {boolean} */ this.challengesSet_ = false; /** @private {!Array.} */ this.notForMe_ = []; } /** @enum {number} */ SingleGnubbySigner.State = { /** Initial state. */ INIT: 0, /** The signer is attempting to open a gnubby. */ OPENING: 1, /** The signer's gnubby opened, but is busy. */ BUSY: 2, /** The signer has an open gnubby, but no challenges to sign. */ IDLE: 3, /** The signer is currently signing a challenge. */ SIGNING: 4, /** The signer got a final outcome. */ COMPLETE: 5, /** The signer is closing its gnubby. */ CLOSING: 6, /** The signer is closed. */ CLOSED: 7 }; /** * @return {llGnubbyDeviceId} This device id of the gnubby for this signer. */ SingleGnubbySigner.prototype.getDeviceId = function() { return this.gnubbyId_; }; /** * Attempts to open this signer's gnubby, if it's not already open. * (This is implicitly done by addChallenges.) */ SingleGnubbySigner.prototype.open = function() { if (this.state_ == SingleGnubbySigner.State.INIT) { this.state_ = SingleGnubbySigner.State.OPENING; this.factory_.openGnubby(this.gnubbyId_, this.forEnroll_, this.openCallback_.bind(this), this.logMsgUrl_); } }; /** * Closes this signer's gnubby, if it's held. */ SingleGnubbySigner.prototype.close = function() { if (!this.gnubby_) return; this.state_ = SingleGnubbySigner.State.CLOSING; this.gnubby_.closeWhenIdle(this.closed_.bind(this)); }; /** * Called when this signer's gnubby is closed. * @private */ SingleGnubbySigner.prototype.closed_ = function() { this.gnubby_ = null; this.state_ = SingleGnubbySigner.State.CLOSED; }; /** * Begins signing the given challenges. * @param {Array.} challenges The challenges to sign. * @return {boolean} Whether the challenges were accepted. */ SingleGnubbySigner.prototype.doSign = function(challenges) { if (this.challengesSet_) { // Can't add new challenges once they've been set. return false; } if (challenges) { console.log(this.gnubby_); console.log(UTIL_fmt('adding ' + challenges.length + ' challenges')); for (var i = 0; i < challenges.length; i++) { this.challenges_.push(challenges[i]); } } this.challengesSet_ = true; switch (this.state_) { case SingleGnubbySigner.State.INIT: this.open(); break; case SingleGnubbySigner.State.OPENING: // The open has already commenced, so accept the challenges, but don't do // anything. break; case SingleGnubbySigner.State.IDLE: if (this.challengeIndex_ < challenges.length) { // Challenges set: start signing. this.doSign_(this.challengeIndex_); } else { // An empty list of challenges can be set during enroll, when the user // has no existing enrolled gnubbies. It's unexpected during sign, but // returning WRONG_DATA satisfies the caller in either case. var self = this; window.setTimeout(function() { self.goToError_(DeviceStatusCodes.WRONG_DATA_STATUS); }, 0); } break; case SingleGnubbySigner.State.SIGNING: // Already signing, so don't kick off a new sign, but accept the added // challenges. break; default: return false; } return true; }; /** * How long to delay retrying a failed open. */ SingleGnubbySigner.OPEN_DELAY_MILLIS = 200; /** * How long to delay retrying a sign requiring touch. */ SingleGnubbySigner.SIGN_DELAY_MILLIS = 200; /** * @param {number} rc The result of the open operation. * @param {usbGnubby=} gnubby The opened gnubby, if open was successful (or * busy). * @private */ SingleGnubbySigner.prototype.openCallback_ = function(rc, gnubby) { if (this.state_ != SingleGnubbySigner.State.OPENING && this.state_ != SingleGnubbySigner.State.BUSY) { // Open completed after close, perhaps? Ignore. return; } switch (rc) { case DeviceStatusCodes.OK_STATUS: if (!gnubby) { console.warn(UTIL_fmt('open succeeded but gnubby is null, WTF?')); } else { this.gnubby_ = gnubby; this.gnubby_.version(this.versionCallback_.bind(this)); } break; case DeviceStatusCodes.BUSY_STATUS: this.gnubby_ = gnubby; this.state_ = SingleGnubbySigner.State.BUSY; // If there's still time, retry the open. if (!this.timer_ || !this.timer_.expired()) { var self = this; window.setTimeout(function() { if (self.gnubby_) { self.factory_.openGnubby(self.gnubbyId_, self.forEnroll_, self.openCallback_.bind(self), self.logMsgUrl_); } }, SingleGnubbySigner.OPEN_DELAY_MILLIS); } else { this.goToError_(DeviceStatusCodes.BUSY_STATUS); } break; default: // TODO: This won't be confused with success, but should it be // part of the same namespace as the other error codes, which are // always in DeviceStatusCodes.*? this.goToError_(rc); } }; /** * Called with the result of a version command. * @param {number} rc Result of version command. * @param {ArrayBuffer=} opt_data Version. * @private */ SingleGnubbySigner.prototype.versionCallback_ = function(rc, opt_data) { if (rc) { this.goToError_(rc); return; } this.state_ = SingleGnubbySigner.State.IDLE; this.version_ = UTIL_BytesToString(new Uint8Array(opt_data || [])); this.doSign_(this.challengeIndex_); }; /** * @param {number} challengeIndex Index of challenge to sign * @private */ SingleGnubbySigner.prototype.doSign_ = function(challengeIndex) { if (!this.gnubby_) { // Already closed? Nothing to do. return; } if (this.timer_ && this.timer_.expired()) { // If the timer is expired, that means we never got a success response. // We could have gotten wrong data on a partial set of challenges, but this // means we don't yet know the final outcome. In any event, we don't yet // know the final outcome: return timeout. this.goToError_(DeviceStatusCodes.TIMEOUT_STATUS); return; } if (!this.challengesSet_) { this.state_ = SingleGnubbySigner.State.IDLE; return; } this.state_ = SingleGnubbySigner.State.SIGNING; if (challengeIndex >= this.challenges_.length) { this.signCallback_(challengeIndex, DeviceStatusCodes.WRONG_DATA_STATUS); return; } var challenge = this.challenges_[challengeIndex]; var challengeHash = challenge.challengeHash; var appIdHash = challenge.appIdHash; var keyHandle = challenge.keyHandle; if (this.notForMe_.indexOf(keyHandle) != -1) { // Cache hit: return wrong data again. this.signCallback_(challengeIndex, DeviceStatusCodes.WRONG_DATA_STATUS); } else if (challenge.version && challenge.version != this.version_) { // Sign challenge for a different version of gnubby: return wrong data. this.signCallback_(challengeIndex, DeviceStatusCodes.WRONG_DATA_STATUS); } else { var nowink = false; this.gnubby_.sign(challengeHash, appIdHash, keyHandle, this.signCallback_.bind(this, challengeIndex), nowink); } }; /** * Called with the result of a single sign operation. * @param {number} challengeIndex the index of the challenge just attempted * @param {number} code the result of the sign operation * @param {ArrayBuffer=} opt_info Optional result data * @private */ SingleGnubbySigner.prototype.signCallback_ = function(challengeIndex, code, opt_info) { console.log(UTIL_fmt('gnubby ' + JSON.stringify(this.gnubbyId_) + ', challenge ' + challengeIndex + ' yielded ' + code.toString(16))); if (this.state_ != SingleGnubbySigner.State.SIGNING) { console.log(UTIL_fmt('already done!')); // We're done, the caller's no longer interested. return; } // Cache wrong data result, re-asking the gnubby to sign it won't produce // different results. if (code == DeviceStatusCodes.WRONG_DATA_STATUS) { if (challengeIndex < this.challenges_.length) { var challenge = this.challenges_[challengeIndex]; if (this.notForMe_.indexOf(challenge.keyHandle) == -1) { this.notForMe_.push(challenge.keyHandle); } } } var self = this; switch (code) { case DeviceStatusCodes.GONE_STATUS: this.goToError_(code); break; case DeviceStatusCodes.TIMEOUT_STATUS: // TODO: On a TIMEOUT_STATUS, sync first, then retry. case DeviceStatusCodes.BUSY_STATUS: this.doSign_(this.challengeIndex_); break; case DeviceStatusCodes.OK_STATUS: if (this.forEnroll_) { this.goToError_(code); } else { this.goToSuccess_(code, this.challenges_[challengeIndex], opt_info); } break; case DeviceStatusCodes.WAIT_TOUCH_STATUS: window.setTimeout(function() { self.doSign_(self.challengeIndex_); }, SingleGnubbySigner.SIGN_DELAY_MILLIS); break; case DeviceStatusCodes.WRONG_DATA_STATUS: if (this.challengeIndex_ < this.challenges_.length - 1) { this.doSign_(++this.challengeIndex_); } else if (this.forEnroll_) { this.goToSuccess_(code); } else { this.goToError_(code); } break; default: if (this.forEnroll_) { this.goToError_(code); } else if (this.challengeIndex_ < this.challenges_.length - 1) { this.doSign_(++this.challengeIndex_); } else { this.goToError_(code); } } }; /** * Switches to the error state, and notifies caller. * @param {number} code Error code * @private */ SingleGnubbySigner.prototype.goToError_ = function(code) { this.state_ = SingleGnubbySigner.State.COMPLETE; console.log(UTIL_fmt('failed (' + code.toString(16) + ')')); // Since this gnubby can no longer produce a useful result, go ahead and // close it. this.close(); var result = { code: code }; this.completeCb_(result); }; /** * Switches to the success state, and notifies caller. * @param {number} code Status code * @param {SignHelperChallenge=} opt_challenge The challenge signed * @param {ArrayBuffer=} opt_info Optional result data * @private */ SingleGnubbySigner.prototype.goToSuccess_ = function(code, opt_challenge, opt_info) { this.state_ = SingleGnubbySigner.State.COMPLETE; console.log(UTIL_fmt('success (' + code.toString(16) + ')')); var result = { code: code, gnubby: this.gnubby_ }; if (opt_challenge || opt_info) { if (opt_challenge) { result['challenge'] = opt_challenge; } if (opt_info) { result['info'] = opt_info; } } this.completeCb_(result); // this.gnubby_ is now owned by completeCb_. this.gnubby_ = null; };