Add new certificateProvider extension API.
[chromium-blink-merge.git] / chrome / browser / resources / cryptotoken / enroller.js
blob496ffa6c32a761afc4a365b9c181f8fd3b1dd79b
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 Handles web page requests for gnubby enrollment.
7  */
9 'use strict';
11 /**
12  * Handles a U2F enroll request.
13  * @param {MessageSender} messageSender The message sender.
14  * @param {Object} request The web page's enroll request.
15  * @param {Function} sendResponse Called back with the result of the enroll.
16  * @return {Closeable} A handler object to be closed when the browser channel
17  *     closes.
18  */
19 function handleU2fEnrollRequest(messageSender, request, sendResponse) {
20   var sentResponse = false;
21   var closeable = null;
23   function sendErrorResponse(error) {
24     var response = makeU2fErrorResponse(request, error.errorCode,
25         error.errorMessage);
26     sendResponseOnce(sentResponse, closeable, response, sendResponse);
27   }
29   function sendSuccessResponse(u2fVersion, info, clientData) {
30     var enrollChallenges = request['registerRequests'];
31     var enrollChallenge =
32         findEnrollChallengeOfVersion(enrollChallenges, u2fVersion);
33     if (!enrollChallenge) {
34       sendErrorResponse({errorCode: ErrorCodes.OTHER_ERROR});
35       return;
36     }
37     var responseData =
38         makeEnrollResponseData(enrollChallenge, u2fVersion, info, clientData);
39     var response = makeU2fSuccessResponse(request, responseData);
40     sendResponseOnce(sentResponse, closeable, response, sendResponse);
41   }
43   function timeout() {
44     sendErrorResponse({errorCode: ErrorCodes.TIMEOUT});
45   }
47   var sender = createSenderFromMessageSender(messageSender);
48   if (!sender) {
49     sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST});
50     return null;
51   }
52   if (sender.origin.indexOf('http://') == 0 && !HTTP_ORIGINS_ALLOWED) {
53     sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST});
54     return null;
55   }
57   if (!isValidEnrollRequest(request)) {
58     sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST});
59     return null;
60   }
62   var timeoutValueSeconds = getTimeoutValueFromRequest(request);
63   // Attenuate watchdog timeout value less than the enroller's timeout, so the
64   // watchdog only fires after the enroller could reasonably have called back,
65   // not before.
66   var watchdogTimeoutValueSeconds = attenuateTimeoutInSeconds(
67       timeoutValueSeconds, MINIMUM_TIMEOUT_ATTENUATION_SECONDS / 2);
68   var watchdog = new WatchdogRequestHandler(watchdogTimeoutValueSeconds,
69       timeout);
70   var wrappedErrorCb = watchdog.wrapCallback(sendErrorResponse);
71   var wrappedSuccessCb = watchdog.wrapCallback(sendSuccessResponse);
73   var timer = createAttenuatedTimer(
74       FACTORY_REGISTRY.getCountdownFactory(), timeoutValueSeconds);
75   var logMsgUrl = request['logMsgUrl'];
76   var enroller = new Enroller(timer, sender, sendErrorResponse,
77       sendSuccessResponse, logMsgUrl);
78   watchdog.setCloseable(/** @type {!Closeable} */ (enroller));
79   closeable = watchdog;
81   var registerRequests = request['registerRequests'];
82   var signRequests = getSignRequestsFromEnrollRequest(request);
83   enroller.doEnroll(registerRequests, signRequests, request['appId']);
85   return closeable;
88 /**
89  * Returns whether the request appears to be a valid enroll request.
90  * @param {Object} request The request.
91  * @return {boolean} Whether the request appears valid.
92  */
93 function isValidEnrollRequest(request) {
94   if (!request.hasOwnProperty('registerRequests'))
95     return false;
96   var enrollChallenges = request['registerRequests'];
97   if (!enrollChallenges.length)
98     return false;
99   var hasAppId = request.hasOwnProperty('appId');
100   if (!isValidEnrollChallengeArray(enrollChallenges, !hasAppId))
101     return false;
102   var signChallenges = getSignChallenges(request);
103   // A missing sign challenge array is ok, in the case the user is not already
104   // enrolled.
105   // A challenge value need not necessarily be supplied with every challenge.
106   var challengeRequired = false;
107   if (signChallenges &&
108       !isValidSignChallengeArray(signChallenges, challengeRequired, !hasAppId))
109     return false;
110   return true;
114  * @typedef {{
115  *   version: (string|undefined),
116  *   challenge: string,
117  *   appId: string
118  * }}
119  */
120 var EnrollChallenge;
123  * @param {Array<EnrollChallenge>} enrollChallenges The enroll challenges to
124  *     validate.
125  * @param {boolean} appIdRequired Whether the appId property is required on
126  *     each challenge.
127  * @return {boolean} Whether the given array of challenges is a valid enroll
128  *     challenges array.
129  */
130 function isValidEnrollChallengeArray(enrollChallenges, appIdRequired) {
131   var seenVersions = {};
132   for (var i = 0; i < enrollChallenges.length; i++) {
133     var enrollChallenge = enrollChallenges[i];
134     var version = enrollChallenge['version'];
135     if (!version) {
136       // Version is implicitly V1 if not specified.
137       version = 'U2F_V1';
138     }
139     if (version != 'U2F_V1' && version != 'U2F_V2') {
140       return false;
141     }
142     if (seenVersions[version]) {
143       // Each version can appear at most once.
144       return false;
145     }
146     seenVersions[version] = version;
147     if (appIdRequired && !enrollChallenge['appId']) {
148       return false;
149     }
150     if (!enrollChallenge['challenge']) {
151       // The challenge is required.
152       return false;
153     }
154   }
155   return true;
159  * Finds the enroll challenge of the given version in the enroll challlenge
160  * array.
161  * @param {Array<EnrollChallenge>} enrollChallenges The enroll challenges to
162  *     search.
163  * @param {string} version Version to search for.
164  * @return {?EnrollChallenge} The enroll challenge with the given versions, or
165  *     null if it isn't found.
166  */
167 function findEnrollChallengeOfVersion(enrollChallenges, version) {
168   for (var i = 0; i < enrollChallenges.length; i++) {
169     if (enrollChallenges[i]['version'] == version) {
170       return enrollChallenges[i];
171     }
172   }
173   return null;
177  * Makes a responseData object for the enroll request with the given parameters.
178  * @param {EnrollChallenge} enrollChallenge The enroll challenge used to
179  *     register.
180  * @param {string} u2fVersion Version of gnubby that enrolled.
181  * @param {string} registrationData The registration data.
182  * @param {string=} opt_clientData The client data, if available.
183  * @return {Object} The responseData object.
184  */
185 function makeEnrollResponseData(enrollChallenge, u2fVersion, registrationData,
186     opt_clientData) {
187   var responseData = {};
188   responseData['registrationData'] = registrationData;
189   // Echo the used challenge back in the reply.
190   for (var k in enrollChallenge) {
191     responseData[k] = enrollChallenge[k];
192   }
193   if (u2fVersion == 'U2F_V2') {
194     // For U2F_V2, the challenge sent to the gnubby is modified to be the
195     // hash of the client data. Include the client data.
196     responseData['clientData'] = opt_clientData;
197   }
198   return responseData;
202  * Gets the expanded sign challenges from an enroll request, potentially by
203  * modifying the request to contain a challenge value where one was omitted.
204  * (For enrolling, the server isn't interested in the value of a signature,
205  * only whether the presented key handle is already enrolled.)
206  * @param {Object} request The request.
207  * @return {Array<SignChallenge>}
208  */
209 function getSignRequestsFromEnrollRequest(request) {
210   var signChallenges;
211   if (request.hasOwnProperty('registeredKeys')) {
212     signChallenges = request['registeredKeys'];
213   } else {
214     signChallenges = request['signRequests'];
215   }
216   if (signChallenges) {
217     for (var i = 0; i < signChallenges.length; i++) {
218       // Make sure each sign challenge has a challenge value.
219       // The actual value doesn't matter, as long as it's a string.
220       if (!signChallenges[i].hasOwnProperty('challenge')) {
221         signChallenges[i]['challenge'] = '';
222       }
223     }
224   }
225   return signChallenges;
229  * Creates a new object to track enrolling with a gnubby.
230  * @param {!Countdown} timer Timer for enroll request.
231  * @param {!WebRequestSender} sender The sender of the request.
232  * @param {function(U2fError)} errorCb Called upon enroll failure.
233  * @param {function(string, string, (string|undefined))} successCb Called upon
234  *     enroll success with the version of the succeeding gnubby, the enroll
235  *     data, and optionally the browser data associated with the enrollment.
236  * @param {string=} opt_logMsgUrl The url to post log messages to.
237  * @constructor
238  */
239 function Enroller(timer, sender, errorCb, successCb, opt_logMsgUrl) {
240   /** @private {Countdown} */
241   this.timer_ = timer;
242   /** @private {WebRequestSender} */
243   this.sender_ = sender;
244   /** @private {function(U2fError)} */
245   this.errorCb_ = errorCb;
246   /** @private {function(string, string, (string|undefined))} */
247   this.successCb_ = successCb;
248   /** @private {string|undefined} */
249   this.logMsgUrl_ = opt_logMsgUrl;
251   /** @private {boolean} */
252   this.done_ = false;
254   /** @private {Object<string, string>} */
255   this.browserData_ = {};
256   /** @private {Array<EnrollHelperChallenge>} */
257   this.encodedEnrollChallenges_ = [];
258   /** @private {Array<SignHelperChallenge>} */
259   this.encodedSignChallenges_ = [];
260   // Allow http appIds for http origins. (Broken, but the caller deserves
261   // what they get.)
262   /** @private {boolean} */
263   this.allowHttp_ =
264       this.sender_.origin ? this.sender_.origin.indexOf('http://') == 0 : false;
265   /** @private {Closeable} */
266   this.handler_ = null;
270  * Default timeout value in case the caller never provides a valid timeout.
271  */
272 Enroller.DEFAULT_TIMEOUT_MILLIS = 30 * 1000;
275  * Performs an enroll request with the given enroll and sign challenges.
276  * @param {Array<EnrollChallenge>} enrollChallenges A set of enroll challenges.
277  * @param {Array<SignChallenge>} signChallenges A set of sign challenges for
278  *     existing enrollments for this user and appId.
279  * @param {string=} opt_appId The app id for the entire request.
280  */
281 Enroller.prototype.doEnroll = function(enrollChallenges, signChallenges,
282     opt_appId) {
283   /** @private {Array<EnrollChallenge>} */
284   this.enrollChallenges_ = enrollChallenges;
285   /** @private {Array<SignChallenge>} */
286   this.signChallenges_ = signChallenges;
287   /** @private {(string|undefined)} */
288   this.appId_ = opt_appId;
289   var self = this;
290   getTabIdWhenPossible(this.sender_).then(function() {
291     if (self.done_) return;
292     self.approveOrigin_();
293   }, function() {
294     self.close();
295     self.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
296   });
300  * Ensures the user has approved this origin to use security keys, sending
301  * to the request to the handler if/when the user has done so.
302  * @private
303  */
304 Enroller.prototype.approveOrigin_ = function() {
305   var self = this;
306   FACTORY_REGISTRY.getApprovedOrigins()
307       .isApprovedOrigin(this.sender_.origin, this.sender_.tabId)
308       .then(function(result) {
309         if (self.done_) return;
310         if (!result) {
311           // Origin not approved: rather than give an explicit indication to
312           // the web page, let a timeout occur.
313           if (self.timer_.expired()) {
314             self.notifyTimeout_();
315             return;
316           }
317           var newTimer = self.timer_.clone(self.notifyTimeout_.bind(self));
318           self.timer_.clearTimeout();
319           self.timer_ = newTimer;
320           return;
321         }
322         self.sendEnrollRequestToHelper_();
323       });
327  * Notifies the caller of a timeout error.
328  * @private
329  */
330 Enroller.prototype.notifyTimeout_ = function() {
331   this.notifyError_({errorCode: ErrorCodes.TIMEOUT});
335  * Performs an enroll request with this instance's enroll and sign challenges,
336  * by encoding them into a helper request and passing the resulting request to
337  * the factory registry's helper.
338  * @private
339  */
340 Enroller.prototype.sendEnrollRequestToHelper_ = function() {
341   var encodedEnrollChallenges =
342       this.encodeEnrollChallenges_(this.enrollChallenges_, this.appId_);
343   // If the request didn't contain a sign challenge, provide one. The value
344   // doesn't matter.
345   var defaultSignChallenge = '';
346   var encodedSignChallenges =
347       encodeSignChallenges(this.signChallenges_, defaultSignChallenge,
348           this.appId_);
349   var request = {
350     type: 'enroll_helper_request',
351     enrollChallenges: encodedEnrollChallenges,
352     signData: encodedSignChallenges,
353     logMsgUrl: this.logMsgUrl_
354   };
355   if (!this.timer_.expired()) {
356     request.timeout = this.timer_.millisecondsUntilExpired() / 1000.0;
357     request.timeoutSeconds = this.timer_.millisecondsUntilExpired() / 1000.0;
358   }
360   // Begin fetching/checking the app ids.
361   var enrollAppIds = [];
362   if (this.appId_) {
363     enrollAppIds.push(this.appId_);
364   }
365   for (var i = 0; i < this.enrollChallenges_.length; i++) {
366     if (this.enrollChallenges_[i].hasOwnProperty('appId')) {
367       enrollAppIds.push(this.enrollChallenges_[i]['appId']);
368     }
369   }
370   // Sanity check
371   if (!enrollAppIds.length) {
372     console.warn(UTIL_fmt('empty enroll app ids?'));
373     this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
374     return;
375   }
376   var self = this;
377   this.checkAppIds_(enrollAppIds, function(result) {
378     if (self.done_) return;
379     if (result) {
380       self.handler_ = FACTORY_REGISTRY.getRequestHelper().getHandler(request);
381       if (self.handler_) {
382         var helperComplete =
383             /** @type {function(HelperReply)} */
384             (self.helperComplete_.bind(self));
385         self.handler_.run(helperComplete);
386       } else {
387         self.notifyError_({errorCode: ErrorCodes.OTHER_ERROR});
388       }
389     } else {
390       self.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
391     }
392   });
396  * Encodes the enroll challenge as an enroll helper challenge.
397  * @param {EnrollChallenge} enrollChallenge The enroll challenge to encode.
398  * @param {string=} opt_appId The app id for the entire request.
399  * @return {EnrollHelperChallenge} The encoded challenge.
400  * @private
401  */
402 Enroller.encodeEnrollChallenge_ = function(enrollChallenge, opt_appId) {
403   var encodedChallenge = {};
404   var version;
405   if (enrollChallenge['version']) {
406     version = enrollChallenge['version'];
407   } else {
408     // Version is implicitly V1 if not specified.
409     version = 'U2F_V1';
410   }
411   encodedChallenge['version'] = version;
412   encodedChallenge['challengeHash'] = enrollChallenge['challenge'];
413   var appId;
414   if (enrollChallenge['appId']) {
415     appId = enrollChallenge['appId'];
416   } else {
417     appId = opt_appId;
418   }
419   if (!appId) {
420     // Sanity check. (Other code should fail if it's not set.)
421     console.warn(UTIL_fmt('No appId?'));
422   }
423   encodedChallenge['appIdHash'] = B64_encode(sha256HashOfString(appId));
424   return /** @type {EnrollHelperChallenge} */ (encodedChallenge);
428  * Encodes the given enroll challenges using this enroller's state.
429  * @param {Array<EnrollChallenge>} enrollChallenges The enroll challenges.
430  * @param {string=} opt_appId The app id for the entire request.
431  * @return {!Array<EnrollHelperChallenge>} The encoded enroll challenges.
432  * @private
433  */
434 Enroller.prototype.encodeEnrollChallenges_ = function(enrollChallenges,
435     opt_appId) {
436   var challenges = [];
437   for (var i = 0; i < enrollChallenges.length; i++) {
438     var enrollChallenge = enrollChallenges[i];
439     var version = enrollChallenge.version;
440     if (!version) {
441       // Version is implicitly V1 if not specified.
442       version = 'U2F_V1';
443     }
445     if (version == 'U2F_V2') {
446       var modifiedChallenge = {};
447       for (var k in enrollChallenge) {
448         modifiedChallenge[k] = enrollChallenge[k];
449       }
450       // V2 enroll responses contain signatures over a browser data object,
451       // which we're constructing here. The browser data object contains, among
452       // other things, the server challenge.
453       var serverChallenge = enrollChallenge['challenge'];
454       var browserData = makeEnrollBrowserData(
455           serverChallenge, this.sender_.origin, this.sender_.tlsChannelId);
456       // Replace the challenge with the hash of the browser data.
457       modifiedChallenge['challenge'] =
458           B64_encode(sha256HashOfString(browserData));
459       this.browserData_[version] =
460           B64_encode(UTIL_StringToBytes(browserData));
461       challenges.push(Enroller.encodeEnrollChallenge_(
462           /** @type {EnrollChallenge} */ (modifiedChallenge), opt_appId));
463     } else {
464       challenges.push(
465           Enroller.encodeEnrollChallenge_(enrollChallenge, opt_appId));
466     }
467   }
468   return challenges;
472  * Checks the app ids associated with this enroll request, and calls a callback
473  * with the result of the check.
474  * @param {!Array<string>} enrollAppIds The app ids in the enroll challenge
475  *     portion of the enroll request.
476  * @param {function(boolean)} cb Called with the result of the check.
477  * @private
478  */
479 Enroller.prototype.checkAppIds_ = function(enrollAppIds, cb) {
480   var appIds =
481       UTIL_unionArrays(enrollAppIds, getDistinctAppIds(this.signChallenges_));
482   FACTORY_REGISTRY.getOriginChecker()
483       .canClaimAppIds(this.sender_.origin, appIds)
484       .then(this.originChecked_.bind(this, appIds, cb));
488  * Called with the result of checking the origin. When the origin is allowed
489  * to claim the app ids, begins checking whether the app ids also list the
490  * origin.
491  * @param {!Array<string>} appIds The app ids.
492  * @param {function(boolean)} cb Called with the result of the check.
493  * @param {boolean} result Whether the origin could claim the app ids.
494  * @private
495  */
496 Enroller.prototype.originChecked_ = function(appIds, cb, result) {
497   if (!result) {
498     this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
499     return;
500   }
501   var appIdChecker = FACTORY_REGISTRY.getAppIdCheckerFactory().create();
502   appIdChecker.
503       checkAppIds(
504           this.timer_.clone(), this.sender_.origin, appIds, this.allowHttp_,
505           this.logMsgUrl_)
506       .then(cb);
509 /** Closes this enroller. */
510 Enroller.prototype.close = function() {
511   if (this.handler_) {
512     this.handler_.close();
513     this.handler_ = null;
514   }
515   this.done_ = true;
519  * Notifies the caller with the error.
520  * @param {U2fError} error Error.
521  * @private
522  */
523 Enroller.prototype.notifyError_ = function(error) {
524   if (this.done_)
525     return;
526   this.close();
527   this.done_ = true;
528   this.errorCb_(error);
532  * Notifies the caller of success with the provided response data.
533  * @param {string} u2fVersion Protocol version
534  * @param {string} info Response data
535  * @param {string|undefined} opt_browserData Browser data used
536  * @private
537  */
538 Enroller.prototype.notifySuccess_ =
539     function(u2fVersion, info, opt_browserData) {
540   if (this.done_)
541     return;
542   this.close();
543   this.done_ = true;
544   this.successCb_(u2fVersion, info, opt_browserData);
548  * Called by the helper upon completion.
549  * @param {EnrollHelperReply} reply The result of the enroll request.
550  * @private
551  */
552 Enroller.prototype.helperComplete_ = function(reply) {
553   if (reply.code) {
554     var reportedError = mapDeviceStatusCodeToU2fError(reply.code);
555     console.log(UTIL_fmt('helper reported ' + reply.code.toString(16) +
556         ', returning ' + reportedError.errorCode));
557     this.notifyError_(reportedError);
558   } else {
559     console.log(UTIL_fmt('Gnubby enrollment succeeded!!!!!'));
560     var browserData;
562     if (reply.version == 'U2F_V2') {
563       // For U2F_V2, the challenge sent to the gnubby is modified to be the hash
564       // of the browser data. Include the browser data.
565       browserData = this.browserData_[reply.version];
566     }
568     this.notifySuccess_(/** @type {string} */ (reply.version),
569                         /** @type {string} */ (reply.enrollData),
570                         browserData);
571   }