• 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 Implements an enroll helper using USB gnubbies.
7 */
8'use strict';
9
10/**
11 * @param {!GnubbyFactory} factory A factory for Gnubby instances
12 * @param {!Countdown} timer A timer for enroll timeout
13 * @param {function(number, boolean)} errorCb Called when an enroll request
14 *     fails with an error code and whether any gnubbies were found.
15 * @param {function(string, string)} successCb Called with the result of a
16 *     successful enroll request, along with the version of the gnubby that
17 *     provided it.
18 * @param {(function(number, boolean)|undefined)} opt_progressCb Called with
19 *     progress updates to the enroll request.
20 * @param {string=} opt_logMsgUrl A URL to post log messages to.
21 * @constructor
22 * @implements {EnrollHelper}
23 */
24function UsbEnrollHelper(factory, timer, errorCb, successCb, opt_progressCb,
25    opt_logMsgUrl) {
26  /** @private {!GnubbyFactory} */
27  this.factory_ = factory;
28  /** @private {!Countdown} */
29  this.timer_ = timer;
30  /** @private {function(number, boolean)} */
31  this.errorCb_ = errorCb;
32  /** @private {function(string, string)} */
33  this.successCb_ = successCb;
34  /** @private {(function(number, boolean)|undefined)} */
35  this.progressCb_ = opt_progressCb;
36  /** @private {string|undefined} */
37  this.logMsgUrl_ = opt_logMsgUrl;
38
39  /** @private {Array.<SignHelperChallenge>} */
40  this.signChallenges_ = [];
41  /** @private {boolean} */
42  this.signChallengesFinal_ = false;
43  /** @private {Array.<usbGnubby>} */
44  this.waitingForTouchGnubbies_ = [];
45
46  /** @private {boolean} */
47  this.closed_ = false;
48  /** @private {boolean} */
49  this.notified_ = false;
50  /** @private {number|undefined} */
51  this.lastProgressUpdate_ = undefined;
52  /** @private {boolean} */
53  this.signerComplete_ = false;
54  this.getSomeGnubbies_();
55}
56
57/**
58 * Attempts to enroll using the provided data.
59 * @param {Object} enrollChallenges a map of version string to enroll
60 *     challenges.
61 * @param {Array.<SignHelperChallenge>} signChallenges a list of sign
62 *     challenges for already enrolled gnubbies, to prevent double-enrolling a
63 *     device.
64 */
65UsbEnrollHelper.prototype.doEnroll =
66    function(enrollChallenges, signChallenges) {
67  this.enrollChallenges = enrollChallenges;
68  this.signChallengesFinal_ = true;
69  if (this.signer_) {
70    this.signer_.addEncodedChallenges(
71        signChallenges, this.signChallengesFinal_);
72  } else {
73    this.signChallenges_ = signChallenges;
74  }
75};
76
77/** Closes this helper. */
78UsbEnrollHelper.prototype.close = function() {
79  this.closed_ = true;
80  for (var i = 0; i < this.waitingForTouchGnubbies_.length; i++) {
81    this.waitingForTouchGnubbies_[i].closeWhenIdle();
82  }
83  this.waitingForTouchGnubbies_ = [];
84  if (this.signer_) {
85    this.signer_.close();
86    this.signer_ = null;
87  }
88};
89
90/**
91 * Enumerates gnubbies, and begins processing challenges upon enumeration if
92 * any gnubbies are found.
93 * @private
94 */
95UsbEnrollHelper.prototype.getSomeGnubbies_ = function() {
96  this.factory_.enumerate(this.enumerateCallback_.bind(this));
97};
98
99/**
100 * Called with the result of enumerating gnubbies.
101 * @param {number} rc the result of the enumerate.
102 * @param {Array.<llGnubbyDeviceId>} indexes Device ids of enumerated gnubbies
103 * @private
104 */
105UsbEnrollHelper.prototype.enumerateCallback_ = function(rc, indexes) {
106  if (rc) {
107    // Enumerate failure is rare enough that it might be worth reporting
108    // directly, rather than trying again.
109    this.errorCb_(rc, false);
110    return;
111  }
112  if (!indexes.length) {
113    this.maybeReEnumerateGnubbies_();
114    return;
115  }
116  if (this.timer_.expired()) {
117    this.errorCb_(DeviceStatusCodes.TIMEOUT_STATUS, true);
118    return;
119  }
120  this.gotSomeGnubbies_(indexes);
121};
122
123/**
124 * If there's still time, re-enumerates devices and try with them. Otherwise
125 * reports an error and, implicitly, stops the enroll operation.
126 * @private
127 */
128UsbEnrollHelper.prototype.maybeReEnumerateGnubbies_ = function() {
129  var errorCode = DeviceStatusCodes.WRONG_DATA_STATUS;
130  var anyGnubbies = false;
131  // If there's still time and we're still going, retry enumerating.
132  if (!this.closed_ && !this.timer_.expired()) {
133    this.notifyProgress_(errorCode, anyGnubbies);
134    var self = this;
135    // Use a delayed re-enumerate to prevent hammering the system unnecessarily.
136    window.setTimeout(function() {
137      if (self.timer_.expired()) {
138        self.notifyError_(errorCode, anyGnubbies);
139      } else {
140        self.getSomeGnubbies_();
141      }
142    }, 200);
143  } else {
144    this.notifyError_(errorCode, anyGnubbies);
145  }
146};
147
148/**
149 * Called with the result of enumerating gnubby indexes.
150 * @param {Array.<llGnubbyDeviceId>} indexes Device ids of enumerated gnubbies
151 * @private
152 */
153UsbEnrollHelper.prototype.gotSomeGnubbies_ = function(indexes) {
154  this.signer_ = new MultipleGnubbySigner(
155      this.factory_,
156      indexes,
157      true /* forEnroll */,
158      this.signerCompleted_.bind(this),
159      this.signerFoundGnubby_.bind(this),
160      this.timer_,
161      this.logMsgUrl_);
162  if (this.signChallengesFinal_) {
163    this.signer_.addEncodedChallenges(
164        this.signChallenges_, this.signChallengesFinal_);
165    this.pendingSignChallenges_ = [];
166  }
167};
168
169/**
170 * Called when a MultipleGnubbySigner completes its sign request.
171 * @param {boolean} anySucceeded whether any sign attempt completed
172 *     successfully.
173 * @param {number=} errorCode an error code from a failing gnubby, if one was
174 *     found.
175 * @private
176 */
177UsbEnrollHelper.prototype.signerCompleted_ = function(anySucceeded, errorCode) {
178  this.signerComplete_ = true;
179  // The signer is not created unless some gnubbies were enumerated, so
180  // anyGnubbies is mostly always true. The exception is when the last gnubby is
181  // removed, handled shortly.
182  var anyGnubbies = true;
183  if (!anySucceeded) {
184    if (errorCode == -llGnubby.GONE) {
185      // If the last gnubby was removed, report as though no gnubbies were
186      // found.
187      this.maybeReEnumerateGnubbies_();
188    } else {
189      if (!errorCode) errorCode = DeviceStatusCodes.WRONG_DATA_STATUS;
190      this.notifyError_(errorCode, anyGnubbies);
191    }
192  } else if (this.anyTimeout) {
193    // Some previously succeeding gnubby timed out: return its error code.
194    this.notifyError_(this.timeoutError, anyGnubbies);
195  } else {
196    // Do nothing: signerFoundGnubby will have been called with each succeeding
197    // gnubby.
198  }
199};
200
201/**
202 * Called when a MultipleGnubbySigner finds a gnubby that can enroll.
203 * @param {number} code Status code
204 * @param {MultipleSignerResult} signResult Signature results
205 * @private
206 */
207UsbEnrollHelper.prototype.signerFoundGnubby_ = function(code, signResult) {
208  var gnubby = signResult['gnubby'];
209  this.waitingForTouchGnubbies_.push(gnubby);
210  this.notifyProgress_(DeviceStatusCodes.WAIT_TOUCH_STATUS, true);
211  if (code == DeviceStatusCodes.WRONG_DATA_STATUS) {
212    if (signResult['challenge']) {
213      // If the signer yielded a busy open, indicate waiting for touch
214      // immediately, rather than attempting enroll. This allows the UI to
215      // update, since a busy open is a potentially long operation.
216      this.notifyError_(DeviceStatusCodes.WAIT_TOUCH_STATUS, true);
217    } else {
218      this.matchEnrollVersionToGnubby_(gnubby);
219    }
220  }
221};
222
223/**
224 * Attempts to match the gnubby's U2F version with an appropriate enroll
225 * challenge.
226 * @param {usbGnubby} gnubby Gnubby instance
227 * @private
228 */
229UsbEnrollHelper.prototype.matchEnrollVersionToGnubby_ = function(gnubby) {
230  if (!gnubby) {
231    console.warn(UTIL_fmt('no gnubby, WTF?'));
232  }
233  gnubby.version(this.gnubbyVersioned_.bind(this, gnubby));
234};
235
236/**
237 * Called with the result of a version command.
238 * @param {usbGnubby} gnubby Gnubby instance
239 * @param {number} rc result of version command.
240 * @param {ArrayBuffer=} data version.
241 * @private
242 */
243UsbEnrollHelper.prototype.gnubbyVersioned_ = function(gnubby, rc, data) {
244  if (rc) {
245    this.removeWrongVersionGnubby_(gnubby);
246    return;
247  }
248  var version = UTIL_BytesToString(new Uint8Array(data || null));
249  this.tryEnroll_(gnubby, version);
250};
251
252/**
253 * Drops the gnubby from the list of eligible gnubbies.
254 * @param {usbGnubby} gnubby Gnubby instance
255 * @private
256 */
257UsbEnrollHelper.prototype.removeWaitingGnubby_ = function(gnubby) {
258  gnubby.closeWhenIdle();
259  var index = this.waitingForTouchGnubbies_.indexOf(gnubby);
260  if (index >= 0) {
261    this.waitingForTouchGnubbies_.splice(index, 1);
262  }
263};
264
265/**
266 * Drops the gnubby from the list of eligible gnubbies, as it has the wrong
267 * version.
268 * @param {usbGnubby} gnubby Gnubby instance
269 * @private
270 */
271UsbEnrollHelper.prototype.removeWrongVersionGnubby_ = function(gnubby) {
272  this.removeWaitingGnubby_(gnubby);
273  if (!this.waitingForTouchGnubbies_.length && this.signerComplete_) {
274    // Whoops, this was the last gnubby: indicate there are none.
275    this.notifyError_(DeviceStatusCodes.WRONG_DATA_STATUS, false);
276  }
277};
278
279/**
280 * Attempts enrolling a particular gnubby with a challenge of the appropriate
281 * version.
282 * @param {usbGnubby} gnubby Gnubby instance
283 * @param {string} version Protocol version
284 * @private
285 */
286UsbEnrollHelper.prototype.tryEnroll_ = function(gnubby, version) {
287  var challenge = this.getChallengeOfVersion_(version);
288  if (!challenge) {
289    this.removeWrongVersionGnubby_(gnubby);
290    return;
291  }
292  var challengeChallenge = B64_decode(challenge['challenge']);
293  var appIdHash = B64_decode(challenge['appIdHash']);
294  gnubby.enroll(challengeChallenge, appIdHash,
295      this.enrollCallback_.bind(this, gnubby, version));
296};
297
298/**
299 * Finds the (first) challenge of the given version in this helper's challenges.
300 * @param {string} version Protocol version
301 * @return {Object} challenge, if found, or null if not.
302 * @private
303 */
304UsbEnrollHelper.prototype.getChallengeOfVersion_ = function(version) {
305  for (var i = 0; i < this.enrollChallenges.length; i++) {
306    if (this.enrollChallenges[i]['version'] == version) {
307      return this.enrollChallenges[i];
308    }
309  }
310  return null;
311};
312
313/**
314 * Called with the result of an enroll request to a gnubby.
315 * @param {usbGnubby} gnubby Gnubby instance
316 * @param {string} version Protocol version
317 * @param {number} code Status code
318 * @param {ArrayBuffer=} infoArray Returned data
319 * @private
320 */
321UsbEnrollHelper.prototype.enrollCallback_ =
322    function(gnubby, version, code, infoArray) {
323  if (this.notified_) {
324    // Enroll completed after previous success or failure. Disregard.
325    return;
326  }
327  switch (code) {
328    case -llGnubby.GONE:
329        // Close this gnubby.
330        this.removeWaitingGnubby_(gnubby);
331        if (!this.waitingForTouchGnubbies_.length) {
332          // Last enroll attempt is complete and last gnubby is gone: retry if
333          // possible.
334          this.maybeReEnumerateGnubbies_();
335        }
336      break;
337
338    case DeviceStatusCodes.WAIT_TOUCH_STATUS:
339    case DeviceStatusCodes.BUSY_STATUS:
340    case DeviceStatusCodes.TIMEOUT_STATUS:
341      if (this.timer_.expired()) {
342        // Store any timeout error code, to be returned from the complete
343        // callback if no other eligible gnubbies are found.
344        this.anyTimeout = true;
345        this.timeoutError = code;
346        // Close this gnubby.
347        this.removeWaitingGnubby_(gnubby);
348        if (!this.waitingForTouchGnubbies_.length && !this.notified_) {
349          // Last enroll attempt is complete: return this error.
350          console.log(UTIL_fmt('timeout (' + code.toString(16) +
351              ') enrolling'));
352          this.notifyError_(code, true);
353        }
354      } else {
355        // Notify caller of waiting for touch events.
356        if (code == DeviceStatusCodes.WAIT_TOUCH_STATUS) {
357          this.notifyProgress_(code, true);
358        }
359        window.setTimeout(this.tryEnroll_.bind(this, gnubby, version), 200);
360      }
361      break;
362
363    case DeviceStatusCodes.OK_STATUS:
364      var info = B64_encode(new Uint8Array(infoArray || []));
365      this.notifySuccess_(version, info);
366      break;
367
368    default:
369      console.log(UTIL_fmt('Failed to enroll gnubby: ' + code));
370      this.notifyError_(code, true);
371      break;
372  }
373};
374
375/**
376 * @param {number} code Status code
377 * @param {boolean} anyGnubbies If any gnubbies were found
378 * @private
379 */
380UsbEnrollHelper.prototype.notifyError_ = function(code, anyGnubbies) {
381  if (this.notified_ || this.closed_)
382    return;
383  this.notified_ = true;
384  this.close();
385  this.errorCb_(code, anyGnubbies);
386};
387
388/**
389 * @param {string} version Protocol version
390 * @param {string} info B64 encoded success data
391 * @private
392 */
393UsbEnrollHelper.prototype.notifySuccess_ = function(version, info) {
394  if (this.notified_ || this.closed_)
395    return;
396  this.notified_ = true;
397  this.close();
398  this.successCb_(version, info);
399};
400
401/**
402 * @param {number} code Status code
403 * @param {boolean} anyGnubbies If any gnubbies were found
404 * @private
405 */
406UsbEnrollHelper.prototype.notifyProgress_ = function(code, anyGnubbies) {
407  if (this.lastProgressUpdate_ == code || this.notified_ || this.closed_)
408    return;
409  this.lastProgressUpdate_ = code;
410  if (this.progressCb_) this.progressCb_(code, anyGnubbies);
411};
412
413/**
414 * @param {!GnubbyFactory} gnubbyFactory factory to create gnubbies.
415 * @constructor
416 * @implements {EnrollHelperFactory}
417 */
418function UsbEnrollHelperFactory(gnubbyFactory) {
419  /** @private {!GnubbyFactory} */
420  this.gnubbyFactory_ = gnubbyFactory;
421}
422
423/**
424 * @param {!Countdown} timer Timeout timer
425 * @param {function(number, boolean)} errorCb Called when an enroll request
426 *     fails with an error code and whether any gnubbies were found.
427 * @param {function(string, string)} successCb Called with the result of a
428 *     successful enroll request, along with the version of the gnubby that
429 *     provided it.
430 * @param {(function(number, boolean)|undefined)} opt_progressCb Called with
431 *     progress updates to the enroll request.
432 * @param {string=} opt_logMsgUrl A URL to post log messages to.
433 * @return {UsbEnrollHelper} the newly created helper.
434 */
435UsbEnrollHelperFactory.prototype.createHelper =
436    function(timer, errorCb, successCb, opt_progressCb, opt_logMsgUrl) {
437  var helper =
438      new UsbEnrollHelper(this.gnubbyFactory_, timer, errorCb, successCb,
439          opt_progressCb, opt_logMsgUrl);
440  return helper;
441};
442