• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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'use strict';
12
13/**
14 * Creates a new sign handler with a gnubby. This handler will perform a sign
15 * operation using each challenge in an array of challenges until its success
16 * condition is satisified, or an error or timeout occurs. The success condition
17 * is defined differently depending whether this signer is used for enrolling
18 * or for signing:
19 *
20 * For enroll, success is defined as each challenge yielding wrong data. This
21 * means this gnubby is not currently enrolled for any of the appIds in any
22 * challenge.
23 *
24 * For sign, success is defined as any challenge yielding ok or waiting for
25 * touch.
26 *
27 * At most one of the success or failure callbacks will be called, and it will
28 * be called at most once. Neither callback is guaranteed to be called: if
29 * a final set of challenges is never given to this gnubby, or if the gnubby
30 * stays busy, the signer has no way to know whether the set of challenges it's
31 * been given has succeeded or failed.
32 * The callback is called only when the signer reaches success or failure, i.e.
33 * when there is no need for this signer to continue trying new challenges.
34 *
35 * @param {!GnubbyFactory} factory Used to create and open the gnubby.
36 * @param {llGnubbyDeviceId} gnubbyIndex Which gnubby to open.
37 * @param {boolean} forEnroll Whether this signer is signing for an attempted
38 *     enroll operation.
39 * @param {function(number)} errorCb Called when this signer fails, i.e. no
40 *     further attempts can succeed.
41 * @param {function(usbGnubby, number, (SingleSignerResult|undefined))}
42 *     successCb Called when this signer succeeds.
43 * @param {Countdown=} opt_timer An advisory timer, beyond whose expiration the
44 *     signer will not attempt any new operations, assuming the caller is no
45 *     longer interested in the outcome.
46 * @param {string=} opt_logMsgUrl A URL to post log messages to.
47 * @constructor
48 */
49function SingleGnubbySigner(factory, gnubbyIndex, forEnroll, errorCb, successCb,
50    opt_timer, opt_logMsgUrl) {
51  /** @private {GnubbyFactory} */
52  this.factory_ = factory;
53  /** @private {llGnubbyDeviceId} */
54  this.gnubbyIndex_ = gnubbyIndex;
55  /** @private {SingleGnubbySigner.State} */
56  this.state_ = SingleGnubbySigner.State.INIT;
57  /** @private {boolean} */
58  this.forEnroll_ = forEnroll;
59  /** @private {function(number)} */
60  this.errorCb_ = errorCb;
61  /** @private {function(usbGnubby, number, (SingleSignerResult|undefined))} */
62  this.successCb_ = successCb;
63  /** @private {Countdown|undefined} */
64  this.timer_ = opt_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.challengesFinal_ = false;
74
75  /** @private {!Array.<string>} */
76  this.notForMe_ = [];
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 encountered an error. */
92  ERROR: 5,
93  /** The signer got a successful outcome. */
94  SUCCESS: 6,
95  /** The signer is closing its gnubby. */
96  CLOSING: 7,
97  /** The signer is closed. */
98  CLOSED: 8
99};
100
101/**
102 * Attempts to open this signer's gnubby, if it's not already open.
103 * (This is implicitly done by addChallenges.)
104 */
105SingleGnubbySigner.prototype.open = function() {
106  if (this.state_ == SingleGnubbySigner.State.INIT) {
107    this.state_ = SingleGnubbySigner.State.OPENING;
108    this.factory_.openGnubby(this.gnubbyIndex_,
109                             this.forEnroll_,
110                             this.openCallback_.bind(this),
111                             this.logMsgUrl_);
112  }
113};
114
115/**
116 * Closes this signer's gnubby, if it's held.
117 */
118SingleGnubbySigner.prototype.close = function() {
119  if (!this.gnubby_) return;
120  this.state_ = SingleGnubbySigner.State.CLOSING;
121  this.gnubby_.closeWhenIdle(this.closed_.bind(this));
122};
123
124/**
125 * Called when this signer's gnubby is closed.
126 * @private
127 */
128SingleGnubbySigner.prototype.closed_ = function() {
129  this.gnubby_ = null;
130  this.state_ = SingleGnubbySigner.State.CLOSED;
131};
132
133/**
134 * Adds challenges to the set of challenges being tried by this signer.
135 * If the signer is currently idle, begins signing the new challenges.
136 *
137 * @param {Array.<SignHelperChallenge>} challenges Sign challenges
138 * @param {boolean} finalChallenges True if there are no more challenges to add
139 * @return {boolean} Whether the challenges were accepted.
140 */
141SingleGnubbySigner.prototype.addChallenges =
142    function(challenges, finalChallenges) {
143  if (this.challengesFinal_) {
144    // Can't add new challenges once they're finalized.
145    return false;
146  }
147
148  if (challenges) {
149    console.log(this.gnubby_);
150    console.log(UTIL_fmt('adding ' + challenges.length + ' challenges'));
151    for (var i = 0; i < challenges.length; i++) {
152      this.challenges_.push(challenges[i]);
153    }
154  }
155  this.challengesFinal_ = finalChallenges;
156
157  switch (this.state_) {
158    case SingleGnubbySigner.State.INIT:
159      this.open();
160      break;
161    case SingleGnubbySigner.State.OPENING:
162      // The open has already commenced, so accept the added challenges, but
163      // don't do anything.
164      break;
165    case SingleGnubbySigner.State.IDLE:
166      if (this.challengeIndex_ < challenges.length) {
167        // New challenges added: restart signing.
168        this.doSign_(this.challengeIndex_);
169      } else if (finalChallenges) {
170        // Finalized with no new challenges can happen when the caller rejects
171        // the appId for some challenge.
172        // If this signer is for enroll, the request must be rejected: this
173        // signer can't determine whether the gnubby is or is not enrolled for
174        // the origin.
175        // If this signer is for sign, the request must also be rejected: there
176        // are no new challenges to sign, and all previous ones did not yield
177        // success.
178        var self = this;
179        window.setTimeout(function() {
180          self.goToError_(DeviceStatusCodes.WRONG_DATA_STATUS);
181        }, 0);
182      }
183      break;
184    case SingleGnubbySigner.State.SIGNING:
185      // Already signing, so don't kick off a new sign, but accept the added
186      // challenges.
187      break;
188    default:
189      return false;
190  }
191  return true;
192};
193
194/**
195 * How long to delay retrying a failed open.
196 */
197SingleGnubbySigner.OPEN_DELAY_MILLIS = 200;
198
199/**
200 * @param {number} rc The result of the open operation.
201 * @param {usbGnubby=} gnubby The opened gnubby, if open was successful (or
202 *     busy).
203 * @private
204 */
205SingleGnubbySigner.prototype.openCallback_ = function(rc, gnubby) {
206  if (this.state_ != SingleGnubbySigner.State.OPENING &&
207      this.state_ != SingleGnubbySigner.State.BUSY) {
208    // Open completed after close, perhaps? Ignore.
209    return;
210  }
211
212  switch (rc) {
213    case DeviceStatusCodes.OK_STATUS:
214      if (!gnubby) {
215        console.warn(UTIL_fmt('open succeeded but gnubby is null, WTF?'));
216      } else {
217        this.gnubby_ = gnubby;
218        this.gnubby_.version(this.versionCallback_.bind(this));
219      }
220      break;
221    case DeviceStatusCodes.BUSY_STATUS:
222      this.gnubby_ = gnubby;
223      this.openedBusy_ = true;
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            self.factory_.openGnubby(self.gnubbyIndex_,
231                                     self.forEnroll_,
232                                     self.openCallback_.bind(self),
233                                     self.logMsgUrl_);
234          }
235        }, SingleGnubbySigner.OPEN_DELAY_MILLIS);
236      } else {
237        this.goToError_(DeviceStatusCodes.BUSY_STATUS);
238      }
239      break;
240    default:
241      // TODO: This won't be confused with success, but should it be
242      // part of the same namespace as the other error codes, which are
243      // always in DeviceStatusCodes.*?
244      this.goToError_(rc);
245  }
246};
247
248/**
249 * Called with the result of a version command.
250 * @param {number} rc Result of version command.
251 * @param {ArrayBuffer=} opt_data Version.
252 * @private
253 */
254SingleGnubbySigner.prototype.versionCallback_ = function(rc, opt_data) {
255  if (rc) {
256    this.goToError_(rc);
257    return;
258  }
259  this.state_ = SingleGnubbySigner.State.IDLE;
260  this.version_ = UTIL_BytesToString(new Uint8Array(opt_data || []));
261  this.doSign_(this.challengeIndex_);
262};
263
264/**
265 * @param {number} challengeIndex Index of challenge to sign
266 * @private
267 */
268SingleGnubbySigner.prototype.doSign_ = function(challengeIndex) {
269  if (this.timer_ && this.timer_.expired()) {
270    // If the timer is expired, that means we never got a success or a touch
271    // required response: either always implies completion of this signer's
272    // state machine (see signCallback's cases for OK_STATUS and
273    // WAIT_TOUCH_STATUS.) We could have gotten wrong data on a partial set of
274    // challenges, but this means we don't yet know the final outcome. In any
275    // event, we don't yet know the final outcome: return busy.
276    this.goToError_(DeviceStatusCodes.BUSY_STATUS);
277    return;
278  }
279
280  this.state_ = SingleGnubbySigner.State.SIGNING;
281
282  if (challengeIndex >= this.challenges_.length) {
283    this.signCallback_(challengeIndex, DeviceStatusCodes.WRONG_DATA_STATUS);
284    return;
285  }
286
287  var challenge = this.challenges_[challengeIndex];
288  var challengeHash = challenge.challengeHash;
289  var appIdHash = challenge.appIdHash;
290  var keyHandle = challenge.keyHandle;
291  if (this.notForMe_.indexOf(keyHandle) != -1) {
292    // Cache hit: return wrong data again.
293    this.signCallback_(challengeIndex, DeviceStatusCodes.WRONG_DATA_STATUS);
294  } else if (challenge.version && challenge.version != this.version_) {
295    // Sign challenge for a different version of gnubby: return wrong data.
296    this.signCallback_(challengeIndex, DeviceStatusCodes.WRONG_DATA_STATUS);
297  } else {
298    var nowink = this.forEnroll_;
299    this.gnubby_.sign(challengeHash, appIdHash, keyHandle,
300        this.signCallback_.bind(this, challengeIndex),
301        nowink);
302  }
303};
304
305/**
306 * Called with the result of a single sign operation.
307 * @param {number} challengeIndex the index of the challenge just attempted
308 * @param {number} code the result of the sign operation
309 * @param {ArrayBuffer=} opt_info Optional result data
310 * @private
311 */
312SingleGnubbySigner.prototype.signCallback_ =
313    function(challengeIndex, code, opt_info) {
314  console.log(UTIL_fmt('gnubby ' + JSON.stringify(this.gnubbyIndex_) +
315      ', challenge ' + challengeIndex + ' yielded ' + code.toString(16)));
316  if (this.state_ != SingleGnubbySigner.State.SIGNING) {
317    console.log(UTIL_fmt('already done!'));
318    // We're done, the caller's no longer interested.
319    return;
320  }
321
322  // Cache wrong data result, re-asking the gnubby to sign it won't produce
323  // different results.
324  if (code == DeviceStatusCodes.WRONG_DATA_STATUS) {
325    if (challengeIndex < this.challenges_.length) {
326      var challenge = this.challenges_[challengeIndex];
327      if (this.notForMe_.indexOf(challenge.keyHandle) == -1) {
328        this.notForMe_.push(challenge.keyHandle);
329      }
330    }
331  }
332
333  switch (code) {
334    case DeviceStatusCodes.GONE_STATUS:
335      this.goToError_(code);
336      break;
337
338    case DeviceStatusCodes.TIMEOUT_STATUS:
339      // TODO: On a TIMEOUT_STATUS, sync first, then retry.
340    case DeviceStatusCodes.BUSY_STATUS:
341      this.doSign_(this.challengeIndex_);
342      break;
343
344    case DeviceStatusCodes.OK_STATUS:
345      if (this.forEnroll_) {
346        this.goToError_(code);
347      } else {
348        this.goToSuccess_(code, this.challenges_[challengeIndex], opt_info);
349      }
350      break;
351
352    case DeviceStatusCodes.WAIT_TOUCH_STATUS:
353      if (this.forEnroll_) {
354        this.goToError_(code);
355      } else {
356        this.goToSuccess_(code, this.challenges_[challengeIndex]);
357      }
358      break;
359
360    case DeviceStatusCodes.WRONG_DATA_STATUS:
361      if (this.challengeIndex_ < this.challenges_.length - 1) {
362        this.doSign_(++this.challengeIndex_);
363      } else if (!this.challengesFinal_) {
364        this.state_ = SingleGnubbySigner.State.IDLE;
365      } else if (this.forEnroll_) {
366        // Signal the caller whether the open was busy, because it may take
367        // an unusually long time when opened for enroll. Use an empty
368        // "challenge" as the signal for a busy open.
369        var challenge = undefined;
370        if (this.openedBusy) {
371          challenge = { appIdHash: '', challengeHash: '', keyHandle: '' };
372        }
373        this.goToSuccess_(code, challenge);
374      } else {
375        this.goToError_(code);
376      }
377      break;
378
379    default:
380      if (this.forEnroll_) {
381        this.goToError_(code);
382      } else if (this.challengeIndex_ < this.challenges_.length - 1) {
383        this.doSign_(++this.challengeIndex_);
384      } else if (!this.challengesFinal_) {
385        // Increment the challenge index, as this one isn't useful any longer,
386        // but a subsequent challenge may appear, and it might be useful.
387        this.challengeIndex_++;
388        this.state_ = SingleGnubbySigner.State.IDLE;
389      } else {
390        this.goToError_(code);
391      }
392  }
393};
394
395/**
396 * Switches to the error state, and notifies caller.
397 * @param {number} code Error code
398 * @private
399 */
400SingleGnubbySigner.prototype.goToError_ = function(code) {
401  this.state_ = SingleGnubbySigner.State.ERROR;
402  console.log(UTIL_fmt('failed (' + code.toString(16) + ')'));
403  this.errorCb_(code);
404  // Since this gnubby can no longer produce a useful result, go ahead and
405  // close it.
406  this.close();
407};
408
409/**
410 * Switches to the success state, and notifies caller.
411 * @param {number} code Status code
412 * @param {SignHelperChallenge=} opt_challenge The challenge signed
413 * @param {ArrayBuffer=} opt_info Optional result data
414 * @private
415 */
416SingleGnubbySigner.prototype.goToSuccess_ =
417    function(code, opt_challenge, opt_info) {
418  this.state_ = SingleGnubbySigner.State.SUCCESS;
419  console.log(UTIL_fmt('success (' + code.toString(16) + ')'));
420  if (opt_challenge || opt_info) {
421    var singleSignerResult = {};
422    if (opt_challenge) {
423      singleSignerResult['challenge'] = opt_challenge;
424    }
425    if (opt_info) {
426      singleSignerResult['info'] = opt_info;
427    }
428  }
429  this.successCb_(this.gnubby_, code, singleSignerResult);
430  // this.gnubby_ is now owned by successCb.
431  this.gnubby_ = null;
432};
433