Add ICU message format support
[chromium-blink-merge.git] / chrome / browser / resources / cryptotoken / singlesigner.js
blobebda03529962d1a5e2d19b48ac382b3f32449e70
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.
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  */
12 'use strict';
14 /**
15  * @typedef {{
16  *   code: number,
17  *   gnubby: (Gnubby|undefined),
18  *   challenge: (SignHelperChallenge|undefined),
19  *   info: (ArrayBuffer|undefined)
20  * }}
21  */
22 var SingleSignerResult;
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  */
53 function 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;
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_ = [];
79 /** @enum {number} */
80 SingleGnubbySigner.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
99 /**
100  * @return {GnubbyDeviceId} This device id of the gnubby for this signer.
101  */
102 SingleGnubbySigner.prototype.getDeviceId = function() {
103   return this.gnubbyId_;
107  * Closes this signer's gnubby, if it's held.
108  */
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.
117  * @private
118  */
119 SingleGnubbySigner.prototype.closed_ = function() {
120   this.gnubby_ = null;
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.
128  */
129 SingleGnubbySigner.prototype.doSign = function(challenges) {
130   if (this.challengesSet_) {
131     // Can't add new challenges once they've been set.
132     return false;
133   }
135   if (challenges) {
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]);
140     }
141   }
142   this.challengesSet_ = true;
144   switch (this.state_) {
145     case SingleGnubbySigner.State.INIT:
146       this.open_();
147       break;
148     case SingleGnubbySigner.State.OPENING:
149       // The open has already commenced, so accept the challenges, but don't do
150       // anything.
151       break;
152     case SingleGnubbySigner.State.IDLE:
153       if (this.challengeIndex_ < challenges.length) {
154         // Challenges set: start signing.
155         this.doSign_(this.challengeIndex_);
156       } else {
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.
160         var self = this;
161         window.setTimeout(function() {
162           self.goToError_(DeviceStatusCodes.WRONG_DATA_STATUS);
163         }, 0);
164       }
165       break;
166     case SingleGnubbySigner.State.SIGNING:
167       // Already signing, so don't kick off a new sign, but accept the added
168       // challenges.
169       break;
170     default:
171       return false;
172   }
173   return true;
177  * Attempts to open this signer's gnubby, if it's not already open.
178  * @private
179  */
180 SingleGnubbySigner.prototype.open_ = function() {
181   var appIdHash;
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);
185   }
186   if (this.state_ == SingleGnubbySigner.State.INIT) {
187     this.state_ = SingleGnubbySigner.State.OPENING;
188     DEVICE_FACTORY_REGISTRY.getGnubbyFactory().openGnubby(
189         this.gnubbyId_,
190         this.forEnroll_,
191         this.openCallback_.bind(this),
192         appIdHash,
193         this.logMsgUrl_);
194   }
198  * How long to delay retrying a failed open.
199  */
200 SingleGnubbySigner.OPEN_DELAY_MILLIS = 200;
203  * How long to delay retrying a sign requiring touch.
204  */
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).
210  * @private
211  */
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.
216     return;
217   }
219   switch (rc) {
220     case DeviceStatusCodes.OK_STATUS:
221       if (!gnubby) {
222         console.warn(UTIL_fmt('open succeeded but gnubby is null, WTF?'));
223       } else {
224         this.gnubby_ = gnubby;
225         this.gnubby_.version(this.versionCallback_.bind(this));
226       }
227       break;
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()) {
233         var self = this;
234         window.setTimeout(function() {
235           if (self.gnubby_) {
236             DEVICE_FACTORY_REGISTRY.getGnubbyFactory().openGnubby(
237                 self.gnubbyId_,
238                 self.forEnroll_,
239                 self.openCallback_.bind(self),
240                 self.logMsgUrl_);
241           }
242         }, SingleGnubbySigner.OPEN_DELAY_MILLIS);
243       } else {
244         this.goToError_(DeviceStatusCodes.BUSY_STATUS);
245       }
246       break;
247     default:
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);
252   }
256  * Called with the result of a version command.
257  * @param {number} rc Result of version command.
258  * @param {ArrayBuffer=} opt_data Version.
259  * @private
260  */
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);
265       return;
266     }
267     // There's still time: resync and retry.
268     var self = this;
269     this.gnubby_.sync(function(code) {
270       if (code) {
271         self.goToError_(code, true);
272         return;
273       }
274       self.gnubby_.version(self.versionCallback_.bind(self));
275     });
276     return;
277   }
278   if (rc) {
279     this.goToError_(rc, true);
280     return;
281   }
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
289  * @private
290  */
291 SingleGnubbySigner.prototype.doSign_ = function(challengeIndex) {
292   if (!this.gnubby_) {
293     // Already closed? Nothing to do.
294     return;
295   }
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);
302     return;
303   }
304   if (!this.challengesSet_) {
305     this.state_ = SingleGnubbySigner.State.IDLE;
306     return;
307   }
309   this.state_ = SingleGnubbySigner.State.SIGNING;
311   if (challengeIndex >= this.challenges_.length) {
312     this.signCallback_(challengeIndex, DeviceStatusCodes.WRONG_DATA_STATUS);
313     return;
314   }
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);
326   } else {
327     var nowink = false;
328     this.gnubby_.sign(challengeHash, appIdHash, keyHandle,
329         this.signCallback_.bind(this, challengeIndex),
330         nowink);
331   }
335  * @param {number} code The result of a sign operation.
336  * @return {boolean} Whether the error indicates the key handle is invalid
337  *     for this gnubby.
338  */
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
350  * @private
351  */
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.
359     return;
360   }
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;
369       }
370     }
371   }
373   var self = this;
374   switch (code) {
375     case DeviceStatusCodes.GONE_STATUS:
376       this.goToError_(code);
377       break;
379     case DeviceStatusCodes.TIMEOUT_STATUS:
380       this.gnubby_.sync(this.synced_.bind(this));
381       break;
383     case DeviceStatusCodes.BUSY_STATUS:
384       this.doSign_(this.challengeIndex_);
385       break;
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?'));
393       }
394       if (this.forEnroll_) {
395         this.goToError_(code);
396       } else {
397         this.goToSuccess_(code, this.challenges_[challengeIndex], opt_info);
398       }
399       break;
401     case DeviceStatusCodes.WAIT_TOUCH_STATUS:
402       window.setTimeout(function() {
403         self.doSign_(self.challengeIndex_);
404       }, SingleGnubbySigner.SIGN_DELAY_MILLIS);
405       break;
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);
414       } else {
415         this.goToError_(code);
416       }
417       break;
419     default:
420       if (this.forEnroll_) {
421         this.goToError_(code, true);
422       } else if (this.challengeIndex_ < this.challenges_.length - 1) {
423         this.doSign_(++this.challengeIndex_);
424       } else {
425         this.goToError_(code, true);
426       }
427   }
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
434  * @private
435  */
436 SingleGnubbySigner.prototype.synced_ = function(code) {
437   if (code) {
438     this.goToError_(code, true);
439     return;
440   }
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.
448  * @private
449  */
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
460     // it here.
461     result.gnubby = this.gnubby_;
462   } else {
463     // Since this gnubby can no longer produce a useful result, go ahead and
464     // close it.
465     this.close();
466   }
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
475  * @private
476  */
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) {
483     if (opt_challenge) {
484       result['challenge'] = opt_challenge;
485     }
486     if (opt_info) {
487       result['info'] = opt_info;
488     }
489   }
490   this.completeCb_(result);
491   // this.gnubby_ is now owned by completeCb_.
492   this.gnubby_ = null;