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 Handles web page requests for gnubby enrollment.
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
19 function handleU2fEnrollRequest(messageSender, request, sendResponse) {
20 var sentResponse = false;
23 function sendErrorResponse(error) {
24 var response = makeU2fErrorResponse(request, error.errorCode,
26 sendResponseOnce(sentResponse, closeable, response, sendResponse);
29 function sendSuccessResponse(u2fVersion, info, clientData) {
30 var enrollChallenges = request['registerRequests'];
32 findEnrollChallengeOfVersion(enrollChallenges, u2fVersion);
33 if (!enrollChallenge) {
34 sendErrorResponse({errorCode: ErrorCodes.OTHER_ERROR});
38 makeEnrollResponseData(enrollChallenge, u2fVersion, info, clientData);
39 var response = makeU2fSuccessResponse(request, responseData);
40 sendResponseOnce(sentResponse, closeable, response, sendResponse);
44 sendErrorResponse({errorCode: ErrorCodes.TIMEOUT});
47 var sender = createSenderFromMessageSender(messageSender);
49 sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST});
52 if (sender.origin.indexOf('http://') == 0 && !HTTP_ORIGINS_ALLOWED) {
53 sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST});
57 if (!isValidEnrollRequest(request)) {
58 sendErrorResponse({errorCode: ErrorCodes.BAD_REQUEST});
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,
66 var watchdogTimeoutValueSeconds = attenuateTimeoutInSeconds(
67 timeoutValueSeconds, MINIMUM_TIMEOUT_ATTENUATION_SECONDS / 2);
68 var watchdog = new WatchdogRequestHandler(watchdogTimeoutValueSeconds,
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));
81 var registerRequests = request['registerRequests'];
82 var signRequests = getSignRequestsFromEnrollRequest(request);
83 enroller.doEnroll(registerRequests, signRequests, request['appId']);
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.
93 function isValidEnrollRequest(request) {
94 if (!request.hasOwnProperty('registerRequests'))
96 var enrollChallenges = request['registerRequests'];
97 if (!enrollChallenges.length)
99 var hasAppId = request.hasOwnProperty('appId');
100 if (!isValidEnrollChallengeArray(enrollChallenges, !hasAppId))
102 var signChallenges = getSignChallenges(request);
103 // A missing sign challenge array is ok, in the case the user is not already
105 // A challenge value need not necessarily be supplied with every challenge.
106 var challengeRequired = false;
107 if (signChallenges &&
108 !isValidSignChallengeArray(signChallenges, challengeRequired, !hasAppId))
115 * version: (string|undefined),
123 * @param {Array<EnrollChallenge>} enrollChallenges The enroll challenges to
125 * @param {boolean} appIdRequired Whether the appId property is required on
127 * @return {boolean} Whether the given array of challenges is a valid enroll
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'];
136 // Version is implicitly V1 if not specified.
139 if (version != 'U2F_V1' && version != 'U2F_V2') {
142 if (seenVersions[version]) {
143 // Each version can appear at most once.
146 seenVersions[version] = version;
147 if (appIdRequired && !enrollChallenge['appId']) {
150 if (!enrollChallenge['challenge']) {
151 // The challenge is required.
159 * Finds the enroll challenge of the given version in the enroll challlenge
161 * @param {Array<EnrollChallenge>} enrollChallenges The enroll challenges to
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.
167 function findEnrollChallengeOfVersion(enrollChallenges, version) {
168 for (var i = 0; i < enrollChallenges.length; i++) {
169 if (enrollChallenges[i]['version'] == version) {
170 return enrollChallenges[i];
177 * Makes a responseData object for the enroll request with the given parameters.
178 * @param {EnrollChallenge} enrollChallenge The enroll challenge used to
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.
185 function makeEnrollResponseData(enrollChallenge, u2fVersion, registrationData,
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];
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;
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>}
209 function getSignRequestsFromEnrollRequest(request) {
211 if (request.hasOwnProperty('registeredKeys')) {
212 signChallenges = request['registeredKeys'];
214 signChallenges = request['signRequests'];
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'] = '';
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.
239 function Enroller(timer, sender, errorCb, successCb, opt_logMsgUrl) {
240 /** @private {Countdown} */
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} */
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
262 /** @private {boolean} */
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.
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.
281 Enroller.prototype.doEnroll = function(enrollChallenges, signChallenges,
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;
290 getTabIdWhenPossible(this.sender_).then(function() {
291 if (self.done_) return;
292 self.approveOrigin_();
295 self.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
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.
304 Enroller.prototype.approveOrigin_ = function() {
306 FACTORY_REGISTRY.getApprovedOrigins()
307 .isApprovedOrigin(this.sender_.origin, this.sender_.tabId)
308 .then(function(result) {
309 if (self.done_) return;
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_();
317 var newTimer = self.timer_.clone(self.notifyTimeout_.bind(self));
318 self.timer_.clearTimeout();
319 self.timer_ = newTimer;
322 self.sendEnrollRequestToHelper_();
327 * Notifies the caller of a timeout error.
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.
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
345 var defaultSignChallenge = '';
346 var encodedSignChallenges =
347 encodeSignChallenges(this.signChallenges_, defaultSignChallenge,
350 type: 'enroll_helper_request',
351 enrollChallenges: encodedEnrollChallenges,
352 signData: encodedSignChallenges,
353 logMsgUrl: this.logMsgUrl_
355 if (!this.timer_.expired()) {
356 request.timeout = this.timer_.millisecondsUntilExpired() / 1000.0;
357 request.timeoutSeconds = this.timer_.millisecondsUntilExpired() / 1000.0;
360 // Begin fetching/checking the app ids.
361 var enrollAppIds = [];
363 enrollAppIds.push(this.appId_);
365 for (var i = 0; i < this.enrollChallenges_.length; i++) {
366 if (this.enrollChallenges_[i].hasOwnProperty('appId')) {
367 enrollAppIds.push(this.enrollChallenges_[i]['appId']);
371 if (!enrollAppIds.length) {
372 console.warn(UTIL_fmt('empty enroll app ids?'));
373 this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
377 this.checkAppIds_(enrollAppIds, function(result) {
378 if (self.done_) return;
380 self.handler_ = FACTORY_REGISTRY.getRequestHelper().getHandler(request);
383 /** @type {function(HelperReply)} */
384 (self.helperComplete_.bind(self));
385 self.handler_.run(helperComplete);
387 self.notifyError_({errorCode: ErrorCodes.OTHER_ERROR});
390 self.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
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.
402 Enroller.encodeEnrollChallenge_ = function(enrollChallenge, opt_appId) {
403 var encodedChallenge = {};
405 if (enrollChallenge['version']) {
406 version = enrollChallenge['version'];
408 // Version is implicitly V1 if not specified.
411 encodedChallenge['version'] = version;
412 encodedChallenge['challengeHash'] = enrollChallenge['challenge'];
414 if (enrollChallenge['appId']) {
415 appId = enrollChallenge['appId'];
420 // Sanity check. (Other code should fail if it's not set.)
421 console.warn(UTIL_fmt('No appId?'));
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.
434 Enroller.prototype.encodeEnrollChallenges_ = function(enrollChallenges,
437 for (var i = 0; i < enrollChallenges.length; i++) {
438 var enrollChallenge = enrollChallenges[i];
439 var version = enrollChallenge.version;
441 // Version is implicitly V1 if not specified.
445 if (version == 'U2F_V2') {
446 var modifiedChallenge = {};
447 for (var k in enrollChallenge) {
448 modifiedChallenge[k] = enrollChallenge[k];
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));
465 Enroller.encodeEnrollChallenge_(enrollChallenge, opt_appId));
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.
479 Enroller.prototype.checkAppIds_ = function(enrollAppIds, cb) {
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
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.
496 Enroller.prototype.originChecked_ = function(appIds, cb, result) {
498 this.notifyError_({errorCode: ErrorCodes.BAD_REQUEST});
501 var appIdChecker = FACTORY_REGISTRY.getAppIdCheckerFactory().create();
504 this.timer_.clone(), this.sender_.origin, appIds, this.allowHttp_,
509 /** Closes this enroller. */
510 Enroller.prototype.close = function() {
512 this.handler_.close();
513 this.handler_ = null;
519 * Notifies the caller with the error.
520 * @param {U2fError} error Error.
523 Enroller.prototype.notifyError_ = function(error) {
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
538 Enroller.prototype.notifySuccess_ =
539 function(u2fVersion, info, opt_browserData) {
544 this.successCb_(u2fVersion, info, opt_browserData);
548 * Called by the helper upon completion.
549 * @param {EnrollHelperReply} reply The result of the enroll request.
552 Enroller.prototype.helperComplete_ = function(reply) {
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);
559 console.log(UTIL_fmt('Gnubby enrollment succeeded!!!!!'));
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];
568 this.notifySuccess_(/** @type {string} */ (reply.version),
569 /** @type {string} */ (reply.enrollData),