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.
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.
17 * gnubby: (Gnubby|undefined),
18 * challenge: (SignHelperChallenge|undefined),
19 * info: (ArrayBuffer|undefined)
22 var SingleSignerResult
;
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
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
35 * For sign, success is defined as any challenge yielding ok.
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
41 * @param {GnubbyDeviceId} gnubbyId Which gnubby to open.
42 * @param {boolean} forEnroll Whether this signer is signing for an attempted
44 * @param {function(SingleSignerResult)}
45 * completeCb Called when this signer completes, i.e. no further results are
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.
53 function SingleGnubbySigner(gnubbyId
, forEnroll
, completeCb
, timer
,
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} */
65 /** @private {string|undefined} */
66 this.logMsgUrl_
= opt_logMsgUrl
;
68 /** @private {!Array<!SignHelperChallenge>} */
69 this.challenges_
= [];
70 /** @private {number} */
71 this.challengeIndex_
= 0;
72 /** @private {boolean} */
73 this.challengesSet_
= false;
75 /** @private {!Object<number>} */
76 this.cachedError_
= [];
80 SingleGnubbySigner
.State
= {
83 /** The signer is attempting to open a gnubby. */
85 /** The signer's gnubby opened, but is busy. */
87 /** The signer has an open gnubby, but no challenges to sign. */
89 /** The signer is currently signing a challenge. */
91 /** The signer got a final outcome. */
93 /** The signer is closing its gnubby. */
95 /** The signer is closed. */
100 * @return {GnubbyDeviceId} This device id of the gnubby for this signer.
102 SingleGnubbySigner
.prototype.getDeviceId = function() {
103 return this.gnubbyId_
;
107 * Closes this signer's gnubby, if it's held.
109 SingleGnubbySigner
.prototype.close = function() {
110 if (!this.gnubby_
) return;
111 this.state_
= SingleGnubbySigner
.State
.CLOSING
;
112 this.gnubby_
.closeWhenIdle(this.closed_
.bind(this));
116 * Called when this signer's gnubby is closed.
119 SingleGnubbySigner
.prototype.closed_ = function() {
121 this.state_
= SingleGnubbySigner
.State
.CLOSED
;
125 * Begins signing the given challenges.
126 * @param {Array<SignHelperChallenge>} challenges The challenges to sign.
127 * @return {boolean} Whether the challenges were accepted.
129 SingleGnubbySigner
.prototype.doSign = function(challenges
) {
130 if (this.challengesSet_
) {
131 // Can't add new challenges once they've been set.
136 console
.log(this.gnubby_
);
137 console
.log(UTIL_fmt('adding ' + challenges
.length
+ ' challenges'));
138 for (var i
= 0; i
< challenges
.length
; i
++) {
139 this.challenges_
.push(challenges
[i
]);
142 this.challengesSet_
= true;
144 switch (this.state_
) {
145 case SingleGnubbySigner
.State
.INIT
:
148 case SingleGnubbySigner
.State
.OPENING
:
149 // The open has already commenced, so accept the challenges, but don't do
152 case SingleGnubbySigner
.State
.IDLE
:
153 if (this.challengeIndex_
< challenges
.length
) {
154 // Challenges set: start signing.
155 this.doSign_(this.challengeIndex_
);
157 // An empty list of challenges can be set during enroll, when the user
158 // has no existing enrolled gnubbies. It's unexpected during sign, but
159 // returning WRONG_DATA satisfies the caller in either case.
161 window
.setTimeout(function() {
162 self
.goToError_(DeviceStatusCodes
.WRONG_DATA_STATUS
);
166 case SingleGnubbySigner
.State
.SIGNING
:
167 // Already signing, so don't kick off a new sign, but accept the added
177 * Attempts to open this signer's gnubby, if it's not already open.
180 SingleGnubbySigner
.prototype.open_ = function() {
182 if (this.challenges_
.length
) {
183 // Assume the first challenge's appId is representative of all of them.
184 appIdHash
= B64_encode(this.challenges_
[0].appIdHash
);
186 if (this.state_
== SingleGnubbySigner
.State
.INIT
) {
187 this.state_
= SingleGnubbySigner
.State
.OPENING
;
188 DEVICE_FACTORY_REGISTRY
.getGnubbyFactory().openGnubby(
191 this.openCallback_
.bind(this),
198 * How long to delay retrying a failed open.
200 SingleGnubbySigner
.OPEN_DELAY_MILLIS
= 200;
203 * How long to delay retrying a sign requiring touch.
205 SingleGnubbySigner
.SIGN_DELAY_MILLIS
= 200;
208 * @param {number} rc The result of the open operation.
209 * @param {Gnubby=} gnubby The opened gnubby, if open was successful (or busy).
212 SingleGnubbySigner
.prototype.openCallback_ = function(rc
, gnubby
) {
213 if (this.state_
!= SingleGnubbySigner
.State
.OPENING
&&
214 this.state_
!= SingleGnubbySigner
.State
.BUSY
) {
215 // Open completed after close, perhaps? Ignore.
220 case DeviceStatusCodes
.OK_STATUS
:
222 console
.warn(UTIL_fmt('open succeeded but gnubby is null, WTF?'));
224 this.gnubby_
= gnubby
;
225 this.gnubby_
.version(this.versionCallback_
.bind(this));
228 case DeviceStatusCodes
.BUSY_STATUS
:
229 this.gnubby_
= gnubby
;
230 this.state_
= SingleGnubbySigner
.State
.BUSY
;
231 // If there's still time, retry the open.
232 if (!this.timer_
|| !this.timer_
.expired()) {
234 window
.setTimeout(function() {
236 DEVICE_FACTORY_REGISTRY
.getGnubbyFactory().openGnubby(
239 self
.openCallback_
.bind(self
),
242 }, SingleGnubbySigner
.OPEN_DELAY_MILLIS
);
244 this.goToError_(DeviceStatusCodes
.BUSY_STATUS
);
248 // TODO: This won't be confused with success, but should it be
249 // part of the same namespace as the other error codes, which are
250 // always in DeviceStatusCodes.*?
251 this.goToError_(rc
, true);
256 * Called with the result of a version command.
257 * @param {number} rc Result of version command.
258 * @param {ArrayBuffer=} opt_data Version.
261 SingleGnubbySigner
.prototype.versionCallback_ = function(rc
, opt_data
) {
262 if (rc
== DeviceStatusCodes
.BUSY_STATUS
) {
263 if (this.timer_
&& this.timer_
.expired()) {
264 this.goToError_(DeviceStatusCodes
.TIMEOUT_STATUS
);
267 // There's still time: resync and retry.
269 this.gnubby_
.sync(function(code
) {
271 self
.goToError_(code
, true);
274 self
.gnubby_
.version(self
.versionCallback_
.bind(self
));
279 this.goToError_(rc
, true);
282 this.state_
= SingleGnubbySigner
.State
.IDLE
;
283 this.version_
= UTIL_BytesToString(new Uint8Array(opt_data
|| []));
284 this.doSign_(this.challengeIndex_
);
288 * @param {number} challengeIndex Index of challenge to sign
291 SingleGnubbySigner
.prototype.doSign_ = function(challengeIndex
) {
293 // Already closed? Nothing to do.
296 if (this.timer_
&& this.timer_
.expired()) {
297 // If the timer is expired, that means we never got a success response.
298 // We could have gotten wrong data on a partial set of challenges, but this
299 // means we don't yet know the final outcome. In any event, we don't yet
300 // know the final outcome: return timeout.
301 this.goToError_(DeviceStatusCodes
.TIMEOUT_STATUS
);
304 if (!this.challengesSet_
) {
305 this.state_
= SingleGnubbySigner
.State
.IDLE
;
309 this.state_
= SingleGnubbySigner
.State
.SIGNING
;
311 if (challengeIndex
>= this.challenges_
.length
) {
312 this.signCallback_(challengeIndex
, DeviceStatusCodes
.WRONG_DATA_STATUS
);
316 var challenge
= this.challenges_
[challengeIndex
];
317 var challengeHash
= challenge
.challengeHash
;
318 var appIdHash
= challenge
.appIdHash
;
319 var keyHandle
= challenge
.keyHandle
;
320 if (this.cachedError_
.hasOwnProperty(keyHandle
)) {
321 // Cache hit: return wrong data again.
322 this.signCallback_(challengeIndex
, this.cachedError_
[keyHandle
]);
323 } else if (challenge
.version
&& challenge
.version
!= this.version_
) {
324 // Sign challenge for a different version of gnubby: return wrong data.
325 this.signCallback_(challengeIndex
, DeviceStatusCodes
.WRONG_DATA_STATUS
);
328 this.gnubby_
.sign(challengeHash
, appIdHash
, keyHandle
,
329 this.signCallback_
.bind(this, challengeIndex
),
335 * @param {number} code The result of a sign operation.
336 * @return {boolean} Whether the error indicates the key handle is invalid
339 SingleGnubbySigner
.signErrorIndicatesInvalidKeyHandle = function(code
) {
340 return (code
== DeviceStatusCodes
.WRONG_DATA_STATUS
||
341 code
== DeviceStatusCodes
.WRONG_LENGTH_STATUS
||
342 code
== DeviceStatusCodes
.INVALID_DATA_STATUS
);
346 * Called with the result of a single sign operation.
347 * @param {number} challengeIndex the index of the challenge just attempted
348 * @param {number} code the result of the sign operation
349 * @param {ArrayBuffer=} opt_info Optional result data
352 SingleGnubbySigner
.prototype.signCallback_
=
353 function(challengeIndex
, code
, opt_info
) {
354 console
.log(UTIL_fmt('gnubby ' + JSON
.stringify(this.gnubbyId_
) +
355 ', challenge ' + challengeIndex
+ ' yielded ' + code
.toString(16)));
356 if (this.state_
!= SingleGnubbySigner
.State
.SIGNING
) {
357 console
.log(UTIL_fmt('already done!'));
358 // We're done, the caller's no longer interested.
362 // Cache certain idempotent errors, re-asking the gnubby to sign it
363 // won't produce different results.
364 if (SingleGnubbySigner
.signErrorIndicatesInvalidKeyHandle(code
)) {
365 if (challengeIndex
< this.challenges_
.length
) {
366 var challenge
= this.challenges_
[challengeIndex
];
367 if (!this.cachedError_
.hasOwnProperty(challenge
.keyHandle
)) {
368 this.cachedError_
[challenge
.keyHandle
] = code
;
375 case DeviceStatusCodes
.GONE_STATUS
:
376 this.goToError_(code
);
379 case DeviceStatusCodes
.TIMEOUT_STATUS
:
380 this.gnubby_
.sync(this.synced_
.bind(this));
383 case DeviceStatusCodes
.BUSY_STATUS
:
384 this.doSign_(this.challengeIndex_
);
387 case DeviceStatusCodes
.OK_STATUS
:
388 // Lower bound on the minimum length, signature length can vary.
389 var MIN_SIGNATURE_LENGTH
= 7;
390 if (!opt_info
|| opt_info
.byteLength
< MIN_SIGNATURE_LENGTH
) {
391 console
.error(UTIL_fmt('Got short response to sign request (' +
392 (opt_info
? opt_info
.byteLength
: 0) + ' bytes), WTF?'));
394 if (this.forEnroll_
) {
395 this.goToError_(code
);
397 this.goToSuccess_(code
, this.challenges_
[challengeIndex
], opt_info
);
401 case DeviceStatusCodes
.WAIT_TOUCH_STATUS
:
402 window
.setTimeout(function() {
403 self
.doSign_(self
.challengeIndex_
);
404 }, SingleGnubbySigner
.SIGN_DELAY_MILLIS
);
407 case DeviceStatusCodes
.WRONG_DATA_STATUS
:
408 case DeviceStatusCodes
.WRONG_LENGTH_STATUS
:
409 case DeviceStatusCodes
.INVALID_DATA_STATUS
:
410 if (this.challengeIndex_
< this.challenges_
.length
- 1) {
411 this.doSign_(++this.challengeIndex_
);
412 } else if (this.forEnroll_
) {
413 this.goToSuccess_(code
);
415 this.goToError_(code
);
420 if (this.forEnroll_
) {
421 this.goToError_(code
, true);
422 } else if (this.challengeIndex_
< this.challenges_
.length
- 1) {
423 this.doSign_(++this.challengeIndex_
);
425 this.goToError_(code
, true);
431 * Called with the response of a sync command, called when a sign yields a
432 * timeout to reassert control over the gnubby.
433 * @param {number} code Error code
436 SingleGnubbySigner
.prototype.synced_ = function(code
) {
438 this.goToError_(code
, true);
441 this.doSign_(this.challengeIndex_
);
445 * Switches to the error state, and notifies caller.
446 * @param {number} code Error code
447 * @param {boolean=} opt_warn Whether to warn in the console about the error.
450 SingleGnubbySigner
.prototype.goToError_ = function(code
, opt_warn
) {
451 this.state_
= SingleGnubbySigner
.State
.COMPLETE
;
452 var logFn
= opt_warn
? console
.warn
.bind(console
) : console
.log
.bind(console
);
453 logFn(UTIL_fmt('failed (' + code
.toString(16) + ')'));
454 var result
= { code
: code
};
455 if (!this.forEnroll_
&& code
== DeviceStatusCodes
.WRONG_DATA_STATUS
) {
456 // When a device yields WRONG_DATA to all sign challenges, and this is a
457 // sign request, we don't want to yield to the web page that it's not
458 // enrolled just yet: we want the user to tap the device first. We'll
459 // report the gnubby to the caller and let it close it instead of closing
461 result
.gnubby
= this.gnubby_
;
463 // Since this gnubby can no longer produce a useful result, go ahead and
467 this.completeCb_(result
);
471 * Switches to the success state, and notifies caller.
472 * @param {number} code Status code
473 * @param {SignHelperChallenge=} opt_challenge The challenge signed
474 * @param {ArrayBuffer=} opt_info Optional result data
477 SingleGnubbySigner
.prototype.goToSuccess_
=
478 function(code
, opt_challenge
, opt_info
) {
479 this.state_
= SingleGnubbySigner
.State
.COMPLETE
;
480 console
.log(UTIL_fmt('success (' + code
.toString(16) + ')'));
481 var result
= { code
: code
, gnubby
: this.gnubby_
};
482 if (opt_challenge
|| opt_info
) {
484 result
['challenge'] = opt_challenge
;
487 result
['info'] = opt_info
;
490 this.completeCb_(result
);
491 // this.gnubby_ is now owned by completeCb_.