// 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;
};
