| // 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 {{ |
| * challengeHash: Array<number>, |
| * appIdHash: Array<number>, |
| * keyHandle: Array<number>, |
| * version: (string|undefined) |
| * }} |
| */ |
| var DecodedSignHelperChallenge; |
| |
| /** |
| * @typedef {{ |
| * code: number, |
| * gnubby: (Gnubby|undefined), |
| * challenge: (DecodedSignHelperChallenge|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. |
| * |
| * The complete 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 {GnubbyDeviceId} 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( |
| gnubbyId, forEnroll, completeCb, timer, opt_logMsgUrl) { |
| /** @private {GnubbyDeviceId} */ |
| 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<!DecodedSignHelperChallenge>} */ |
| this.challenges_ = []; |
| /** @private {number} */ |
| this.challengeIndex_ = 0; |
| /** @private {boolean} */ |
| this.challengesSet_ = false; |
| |
| /** @private {!Object<Array<number>, number>} */ |
| this.cachedError_ = []; |
| |
| /** @private {(function()|undefined)} */ |
| this.openCanceller_; |
| } |
| |
| /** @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 {GnubbyDeviceId} This device id of the gnubby for this signer. |
| */ |
| SingleGnubbySigner.prototype.getDeviceId = function() { |
| return this.gnubbyId_; |
| }; |
| |
| /** |
| * Closes this signer's gnubby, if it's held. |
| */ |
| SingleGnubbySigner.prototype.close = function() { |
| if (this.state_ == SingleGnubbySigner.State.OPENING) { |
| if (this.openCanceller_) |
| this.openCanceller_(); |
| } |
| |
| 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<DecodedSignHelperChallenge>} 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; |
| }; |
| |
| /** |
| * Attempts to open this signer's gnubby, if it's not already open. |
| * @private |
| */ |
| SingleGnubbySigner.prototype.open_ = function() { |
| var appIdHash; |
| if (this.challenges_.length) { |
| // Assume the first challenge's appId is representative of all of them. |
| appIdHash = B64_encode(this.challenges_[0].appIdHash); |
| } |
| if (this.state_ == SingleGnubbySigner.State.INIT) { |
| this.state_ = SingleGnubbySigner.State.OPENING; |
| this.openCanceller_ = DEVICE_FACTORY_REGISTRY.getGnubbyFactory().openGnubby( |
| this.gnubbyId_, this.forEnroll_, this.openCallback_.bind(this), |
| appIdHash, this.logMsgUrl_, |
| 'singlesigner.js:SingleGnubbySigner.prototype.open_'); |
| } |
| }; |
| |
| /** |
| * 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 {Gnubby=} 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_) { |
| this.openCanceller_ = |
| DEVICE_FACTORY_REGISTRY.getGnubbyFactory().openGnubby( |
| self.gnubbyId_, self.forEnroll_, |
| self.openCallback_.bind(self), self.logMsgUrl_, |
| 'singlesigner.js:SingleGnubbySigner.prototype.openCallback_'); |
| } |
| }, 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, true); |
| } |
| }; |
| |
| /** |
| * 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 == DeviceStatusCodes.BUSY_STATUS) { |
| if (this.timer_ && this.timer_.expired()) { |
| this.goToError_(DeviceStatusCodes.TIMEOUT_STATUS); |
| return; |
| } |
| // There's still time: resync and retry. |
| var self = this; |
| this.gnubby_.sync(function(code) { |
| if (code) { |
| self.goToError_(code, true); |
| return; |
| } |
| self.gnubby_.version(self.versionCallback_.bind(self)); |
| }); |
| return; |
| } |
| if (rc) { |
| this.goToError_(rc, true); |
| 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.cachedError_.hasOwnProperty(keyHandle)) { |
| // Cache hit: return wrong data again. |
| this.signCallback_(challengeIndex, this.cachedError_[keyHandle]); |
| } 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); |
| } |
| }; |
| |
| /** |
| * @param {number} code The result of a sign operation. |
| * @return {boolean} Whether the error indicates the key handle is invalid |
| * for this gnubby. |
| */ |
| SingleGnubbySigner.signErrorIndicatesInvalidKeyHandle = function(code) { |
| // Negative errors are synthetic, device-level errors, rather than APDU-layer |
| // things. Wait for touch is the only error code defined to be a transient |
| // situation. Unfortunately the spec is ambiguous, and some devices behave |
| // oddly, so we treat all APDU-layer errors as idempotent rather than |
| // transient. |
| return code > 0 && code != DeviceStatusCodes.WAIT_TOUCH_STATUS; |
| }; |
| |
| /** |
| * 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 certain idempotent errors, re-asking the gnubby to sign it |
| // won't produce different results. |
| if (SingleGnubbySigner.signErrorIndicatesInvalidKeyHandle(code)) { |
| if (challengeIndex < this.challenges_.length) { |
| var challenge = this.challenges_[challengeIndex]; |
| if (!this.cachedError_.hasOwnProperty(challenge.keyHandle)) { |
| this.cachedError_[challenge.keyHandle] = code; |
| } |
| } |
| } |
| |
| var self = this; |
| switch (code) { |
| case DeviceStatusCodes.TIMEOUT_STATUS: |
| this.gnubby_.sync(this.synced_.bind(this)); |
| break; |
| |
| 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; |
| |
| default: |
| if (code < 0) { |
| // Negative errors are synthetic, device-level errors, rather than |
| // APDU-layer things. Other than the ones explicitly handled above, |
| // these are indicative of unhappy devices, so return them immediately |
| // to the caller. |
| this.goToError_(code); |
| return; |
| } |
| |
| if (this.challengeIndex_ < this.challenges_.length - 1) { |
| this.doSign_(++this.challengeIndex_); |
| } else if (this.forEnroll_) { |
| this.goToSuccess_(code); |
| } else { |
| this.goToError_(code); |
| } |
| } |
| }; |
| |
| /** |
| * Called with the response of a sync command, called when a sign yields a |
| * timeout to reassert control over the gnubby. |
| * @param {number} code Error code |
| * @private |
| */ |
| SingleGnubbySigner.prototype.synced_ = function(code) { |
| if (code) { |
| this.goToError_(code, true); |
| return; |
| } |
| this.doSign_(this.challengeIndex_); |
| }; |
| |
| /** |
| * Switches to the error state, and notifies caller. |
| * @param {number} code Error code |
| * @param {boolean=} opt_warn Whether to warn in the console about the error. |
| * @private |
| */ |
| SingleGnubbySigner.prototype.goToError_ = function(code, opt_warn) { |
| this.state_ = SingleGnubbySigner.State.COMPLETE; |
| var logFn = opt_warn ? console.warn.bind(console) : console.log.bind(console); |
| logFn(UTIL_fmt('failed (' + code.toString(16) + ')')); |
| var result = {code: code}; |
| if (!this.forEnroll_ && |
| SingleGnubbySigner.signErrorIndicatesInvalidKeyHandle(code)) { |
| // When a device yields an idempotent bad key handle error to all sign |
| // challenges, and this is a sign request, we don't want to yield to the |
| // web page that it's not enrolled just yet: we want the user to tap the |
| // device first. We'll report the gnubby to the caller and let it close it |
| // instead of closing it here. |
| result.gnubby = this.gnubby_; |
| } else { |
| // Since this gnubby can no longer produce a useful result, go ahead and |
| // close it. |
| this.close(); |
| } |
| this.completeCb_(result); |
| }; |
| |
| /** |
| * Switches to the success state, and notifies caller. |
| * @param {number} code Status code |
| * @param {DecodedSignHelperChallenge=} 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; |
| }; |