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