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 web 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 handleWebEnrollRequest(messageSender
, request
, sendResponse
) {
20 var sentResponse
= false;
23 function sendErrorResponse(error
) {
24 var response
= makeWebErrorResponse(request
,
25 mapErrorCodeToGnubbyCodeType(error
.errorCode
, false /* forSign */));
26 sendResponseOnce(sentResponse
, closeable
, response
, sendResponse
);
29 function sendSuccessResponse(u2fVersion
, info
, browserData
) {
30 var enrollChallenges
= request
['enrollChallenges'];
32 findEnrollChallengeOfVersion(enrollChallenges
, u2fVersion
);
33 if (!enrollChallenge
) {
34 sendErrorResponse({errorCode
: ErrorCodes
.OTHER_ERROR
});
38 makeEnrollResponseData(enrollChallenge
, u2fVersion
,
39 'enrollData', info
, 'browserData', browserData
);
40 var response
= makeWebSuccessResponse(request
, responseData
);
41 sendResponseOnce(sentResponse
, closeable
, response
, sendResponse
);
45 sendErrorResponse({errorCode
: ErrorCodes
.TIMEOUT
});
48 var sender
= createSenderFromMessageSender(messageSender
);
50 sendErrorResponse({errorCode
: ErrorCodes
.BAD_REQUEST
});
53 if (sender
.origin
.indexOf('http://') == 0 && !HTTP_ORIGINS_ALLOWED
) {
54 sendErrorResponse({errorCode
: ErrorCodes
.BAD_REQUEST
});
58 if (!isValidEnrollRequest(request
, 'enrollChallenges', 'signData')) {
59 sendErrorResponse({errorCode
: ErrorCodes
.BAD_REQUEST
});
63 var timeoutValueSeconds
= getTimeoutValueFromRequest(request
);
64 // Attenuate watchdog timeout value less than the enroller's timeout, so the
65 // watchdog only fires after the enroller could reasonably have called back,
67 var watchdogTimeoutValueSeconds
= attenuateTimeoutInSeconds(
68 timeoutValueSeconds
, MINIMUM_TIMEOUT_ATTENUATION_SECONDS
/ 2);
69 var watchdog
= new WatchdogRequestHandler(watchdogTimeoutValueSeconds
,
71 var wrappedErrorCb
= watchdog
.wrapCallback(sendErrorResponse
);
72 var wrappedSuccessCb
= watchdog
.wrapCallback(sendSuccessResponse
);
74 var timer
= createAttenuatedTimer(
75 FACTORY_REGISTRY
.getCountdownFactory(), timeoutValueSeconds
);
76 var logMsgUrl
= request
['logMsgUrl'];
77 var enroller
= new Enroller(timer
, sender
, wrappedErrorCb
, wrappedSuccessCb
,
79 watchdog
.setCloseable(/** @type {!Closeable} */ (enroller
));
82 var registerRequests
= request
['enrollChallenges'];
83 var signRequests
= getSignRequestsFromEnrollRequest(request
, 'signData');
84 enroller
.doEnroll(registerRequests
, signRequests
, request
['appId']);
90 * Handles a U2F enroll request.
91 * @param {MessageSender} messageSender The message sender.
92 * @param {Object} request The web page's enroll request.
93 * @param {Function} sendResponse Called back with the result of the enroll.
94 * @return {Closeable} A handler object to be closed when the browser channel
97 function handleU2fEnrollRequest(messageSender
, request
, sendResponse
) {
98 var sentResponse
= false;
101 function sendErrorResponse(error
) {
102 var response
= makeU2fErrorResponse(request
, error
.errorCode
,
104 sendResponseOnce(sentResponse
, closeable
, response
, sendResponse
);
107 function sendSuccessResponse(u2fVersion
, info
, browserData
) {
108 var enrollChallenges
= request
['registerRequests'];
109 var enrollChallenge
=
110 findEnrollChallengeOfVersion(enrollChallenges
, u2fVersion
);
111 if (!enrollChallenge
) {
112 sendErrorResponse({errorCode
: ErrorCodes
.OTHER_ERROR
});
116 makeEnrollResponseData(enrollChallenge
, u2fVersion
,
117 'registrationData', info
, 'clientData', browserData
);
118 var response
= makeU2fSuccessResponse(request
, responseData
);
119 sendResponseOnce(sentResponse
, closeable
, response
, sendResponse
);
123 sendErrorResponse({errorCode
: ErrorCodes
.TIMEOUT
});
126 var sender
= createSenderFromMessageSender(messageSender
);
128 sendErrorResponse({errorCode
: ErrorCodes
.BAD_REQUEST
});
131 if (sender
.origin
.indexOf('http://') == 0 && !HTTP_ORIGINS_ALLOWED
) {
132 sendErrorResponse({errorCode
: ErrorCodes
.BAD_REQUEST
});
136 if (!isValidEnrollRequest(request
, 'registerRequests', 'signRequests',
138 sendErrorResponse({errorCode
: ErrorCodes
.BAD_REQUEST
});
142 var timeoutValueSeconds
= getTimeoutValueFromRequest(request
);
143 // Attenuate watchdog timeout value less than the enroller's timeout, so the
144 // watchdog only fires after the enroller could reasonably have called back,
146 var watchdogTimeoutValueSeconds
= attenuateTimeoutInSeconds(
147 timeoutValueSeconds
, MINIMUM_TIMEOUT_ATTENUATION_SECONDS
/ 2);
148 var watchdog
= new WatchdogRequestHandler(watchdogTimeoutValueSeconds
,
150 var wrappedErrorCb
= watchdog
.wrapCallback(sendErrorResponse
);
151 var wrappedSuccessCb
= watchdog
.wrapCallback(sendSuccessResponse
);
153 var timer
= createAttenuatedTimer(
154 FACTORY_REGISTRY
.getCountdownFactory(), timeoutValueSeconds
);
155 var logMsgUrl
= request
['logMsgUrl'];
156 var enroller
= new Enroller(timer
, sender
, sendErrorResponse
,
157 sendSuccessResponse
, logMsgUrl
);
158 watchdog
.setCloseable(/** @type {!Closeable} */ (enroller
));
159 closeable
= watchdog
;
161 var registerRequests
= request
['registerRequests'];
162 var signRequests
= getSignRequestsFromEnrollRequest(request
,
163 'signRequests', 'registeredKeys');
164 enroller
.doEnroll(registerRequests
, signRequests
, request
['appId']);
170 * Returns whether the request appears to be a valid enroll request.
171 * @param {Object} request The request.
172 * @param {string} enrollChallengesName The name of the enroll challenges value
174 * @param {string} signChallengesName The name of the sign challenges value in
176 * @param {string=} opt_registeredKeysName The name of the registered keys
177 * value in the request.
178 * @return {boolean} Whether the request appears valid.
180 function isValidEnrollRequest(request
, enrollChallengesName
,
181 signChallengesName
, opt_registeredKeysName
) {
182 if (!request
.hasOwnProperty(enrollChallengesName
))
184 var enrollChallenges
= request
[enrollChallengesName
];
185 if (!enrollChallenges
.length
)
187 var hasAppId
= request
.hasOwnProperty('appId');
188 if (!isValidEnrollChallengeArray(enrollChallenges
, !hasAppId
))
190 var signChallenges
= request
[signChallengesName
];
191 // A missing sign challenge array is ok, in the case the user is not already
193 // A challenge value need not necessarily be supplied with every challenge.
194 var challengeRequired
= false;
195 if (signChallenges
&&
196 !isValidSignChallengeArray(signChallenges
, challengeRequired
, !hasAppId
))
198 if (opt_registeredKeysName
) {
199 var registeredKeys
= request
[opt_registeredKeysName
];
200 if (registeredKeys
&&
201 !isValidRegisteredKeyArray(registeredKeys
, !hasAppId
)) {
210 * version: (string|undefined),
218 * @param {Array<EnrollChallenge>} enrollChallenges The enroll challenges to
220 * @param {boolean} appIdRequired Whether the appId property is required on
222 * @return {boolean} Whether the given array of challenges is a valid enroll
225 function isValidEnrollChallengeArray(enrollChallenges
, appIdRequired
) {
226 var seenVersions
= {};
227 for (var i
= 0; i
< enrollChallenges
.length
; i
++) {
228 var enrollChallenge
= enrollChallenges
[i
];
229 var version
= enrollChallenge
['version'];
231 // Version is implicitly V1 if not specified.
234 if (version
!= 'U2F_V1' && version
!= 'U2F_V2') {
237 if (seenVersions
[version
]) {
238 // Each version can appear at most once.
241 seenVersions
[version
] = version
;
242 if (appIdRequired
&& !enrollChallenge
['appId']) {
245 if (!enrollChallenge
['challenge']) {
246 // The challenge is required.
254 * Finds the enroll challenge of the given version in the enroll challlenge
256 * @param {Array<EnrollChallenge>} enrollChallenges The enroll challenges to
258 * @param {string} version Version to search for.
259 * @return {?EnrollChallenge} The enroll challenge with the given versions, or
260 * null if it isn't found.
262 function findEnrollChallengeOfVersion(enrollChallenges
, version
) {
263 for (var i
= 0; i
< enrollChallenges
.length
; i
++) {
264 if (enrollChallenges
[i
]['version'] == version
) {
265 return enrollChallenges
[i
];
272 * Makes a responseData object for the enroll request with the given parameters.
273 * @param {EnrollChallenge} enrollChallenge The enroll challenge used to
275 * @param {string} u2fVersion Version of gnubby that enrolled.
276 * @param {string} enrollDataName The name of the enroll data key in the
277 * responseData object.
278 * @param {string} enrollData The enroll data.
279 * @param {string} browserDataName The name of the browser data key in the
280 * responseData object.
281 * @param {string=} browserData The browser data, if available.
282 * @return {Object} The responseData object.
284 function makeEnrollResponseData(enrollChallenge
, u2fVersion
, enrollDataName
,
285 enrollData
, browserDataName
, browserData
) {
286 var responseData
= {};
287 responseData
[enrollDataName
] = enrollData
;
288 // Echo the used challenge back in the reply.
289 for (var k
in enrollChallenge
) {
290 responseData
[k
] = enrollChallenge
[k
];
292 if (u2fVersion
== 'U2F_V2') {
293 // For U2F_V2, the challenge sent to the gnubby is modified to be the
294 // hash of the browser data. Include the browser data.
295 responseData
[browserDataName
] = browserData
;
301 * Gets the expanded sign challenges from an enroll request, potentially by
302 * modifying the request to contain a challenge value where one was omitted.
303 * (For enrolling, the server isn't interested in the value of a signature,
304 * only whether the presented key handle is already enrolled.)
305 * @param {Object} request The request.
306 * @param {string} signChallengesName The name of the sign challenges value in
308 * @param {string=} opt_registeredKeysName The name of the registered keys
309 * value in the request.
310 * @return {Array<SignChallenge>}
312 function getSignRequestsFromEnrollRequest(request
, signChallengesName
,
313 opt_registeredKeysName
) {
315 if (opt_registeredKeysName
&&
316 request
.hasOwnProperty(opt_registeredKeysName
)) {
317 signChallenges
= request
[opt_registeredKeysName
];
319 signChallenges
= request
[signChallengesName
];
321 if (signChallenges
) {
322 for (var i
= 0; i
< signChallenges
.length
; i
++) {
323 // Make sure each sign challenge has a challenge value.
324 // The actual value doesn't matter, as long as it's a string.
325 if (!signChallenges
[i
].hasOwnProperty('challenge')) {
326 signChallenges
[i
]['challenge'] = '';
330 return signChallenges
;
334 * Creates a new object to track enrolling with a gnubby.
335 * @param {!Countdown} timer Timer for enroll request.
336 * @param {!WebRequestSender} sender The sender of the request.
337 * @param {function(U2fError)} errorCb Called upon enroll failure.
338 * @param {function(string, string, (string|undefined))} successCb Called upon
339 * enroll success with the version of the succeeding gnubby, the enroll
340 * data, and optionally the browser data associated with the enrollment.
341 * @param {string=} opt_logMsgUrl The url to post log messages to.
344 function Enroller(timer
, sender
, errorCb
, successCb
, opt_logMsgUrl
) {
345 /** @private {Countdown} */
347 /** @private {WebRequestSender} */
348 this.sender_
= sender
;
349 /** @private {function(U2fError)} */
350 this.errorCb_
= errorCb
;
351 /** @private {function(string, string, (string|undefined))} */
352 this.successCb_
= successCb
;
353 /** @private {string|undefined} */
354 this.logMsgUrl_
= opt_logMsgUrl
;
356 /** @private {boolean} */
359 /** @private {Object<string>} */
360 this.browserData_
= {};
361 /** @private {Array<EnrollHelperChallenge>} */
362 this.encodedEnrollChallenges_
= [];
363 /** @private {Array<SignHelperChallenge>} */
364 this.encodedSignChallenges_
= [];
365 // Allow http appIds for http origins. (Broken, but the caller deserves
367 /** @private {boolean} */
369 this.sender_
.origin
? this.sender_
.origin
.indexOf('http://') == 0 : false;
370 /** @private {Closeable} */
371 this.handler_
= null;
375 * Default timeout value in case the caller never provides a valid timeout.
377 Enroller
.DEFAULT_TIMEOUT_MILLIS
= 30 * 1000;
380 * Performs an enroll request with the given enroll and sign challenges.
381 * @param {Array<EnrollChallenge>} enrollChallenges A set of enroll challenges.
382 * @param {Array<SignChallenge>} signChallenges A set of sign challenges for
383 * existing enrollments for this user and appId.
384 * @param {string=} opt_appId The app id for the entire request.
386 Enroller
.prototype.doEnroll = function(enrollChallenges
, signChallenges
,
388 /** @private {Array<EnrollChallenge>} */
389 this.enrollChallenges_
= enrollChallenges
;
390 /** @private {Array<SignChallenge>} */
391 this.signChallenges_
= signChallenges
;
392 /** @private {(string|undefined)} */
393 this.appId_
= opt_appId
;
395 getTabIdWhenPossible(this.sender_
).then(function() {
396 if (self
.done_
) return;
397 self
.approveOrigin_();
400 self
.notifyError_({errorCode
: ErrorCodes
.BAD_REQUEST
});
405 * Ensures the user has approved this origin to use security keys, sending
406 * to the request to the handler if/when the user has done so.
409 Enroller
.prototype.approveOrigin_ = function() {
411 FACTORY_REGISTRY
.getApprovedOrigins()
412 .isApprovedOrigin(this.sender_
.origin
, this.sender_
.tabId
)
413 .then(function(result
) {
414 if (self
.done_
) return;
416 // Origin not approved: rather than give an explicit indication to
417 // the web page, let a timeout occur.
418 if (self
.timer_
.expired()) {
419 self
.notifyTimeout_();
422 var newTimer
= self
.timer_
.clone(self
.notifyTimeout_
.bind(self
));
423 self
.timer_
.clearTimeout();
424 self
.timer_
= newTimer
;
427 self
.sendEnrollRequestToHelper_();
432 * Notifies the caller of a timeout error.
435 Enroller
.prototype.notifyTimeout_ = function() {
436 this.notifyError_({errorCode
: ErrorCodes
.TIMEOUT
});
440 * Performs an enroll request with this instance's enroll and sign challenges,
441 * by encoding them into a helper request and passing the resulting request to
442 * the factory registry's helper.
445 Enroller
.prototype.sendEnrollRequestToHelper_ = function() {
446 var encodedEnrollChallenges
=
447 this.encodeEnrollChallenges_(this.enrollChallenges_
, this.appId_
);
448 // If the request didn't contain a sign challenge, provide one. The value
450 var defaultSignChallenge
= '';
451 var encodedSignChallenges
=
452 encodeSignChallenges(this.signChallenges_
, defaultSignChallenge
,
455 type
: 'enroll_helper_request',
456 enrollChallenges
: encodedEnrollChallenges
,
457 signData
: encodedSignChallenges
,
458 logMsgUrl
: this.logMsgUrl_
460 if (!this.timer_
.expired()) {
461 request
.timeout
= this.timer_
.millisecondsUntilExpired() / 1000.0;
462 request
.timeoutSeconds
= this.timer_
.millisecondsUntilExpired() / 1000.0;
465 // Begin fetching/checking the app ids.
466 var enrollAppIds
= [];
468 enrollAppIds
.push(this.appId_
);
470 for (var i
= 0; i
< this.enrollChallenges_
.length
; i
++) {
471 if (this.enrollChallenges_
[i
].hasOwnProperty('appId')) {
472 enrollAppIds
.push(this.enrollChallenges_
[i
]['appId']);
476 if (!enrollAppIds
.length
) {
477 console
.warn(UTIL_fmt('empty enroll app ids?'));
478 this.notifyError_({errorCode
: ErrorCodes
.BAD_REQUEST
});
482 this.checkAppIds_(enrollAppIds
, function(result
) {
483 if (self
.done_
) return;
485 self
.handler_
= FACTORY_REGISTRY
.getRequestHelper().getHandler(request
);
488 /** @type {function(HelperReply)} */
489 (self
.helperComplete_
.bind(self
));
490 self
.handler_
.run(helperComplete
);
492 self
.notifyError_({errorCode
: ErrorCodes
.OTHER_ERROR
});
495 self
.notifyError_({errorCode
: ErrorCodes
.BAD_REQUEST
});
501 * Encodes the enroll challenge as an enroll helper challenge.
502 * @param {EnrollChallenge} enrollChallenge The enroll challenge to encode.
503 * @param {string=} opt_appId The app id for the entire request.
504 * @return {EnrollHelperChallenge} The encoded challenge.
507 Enroller
.encodeEnrollChallenge_ = function(enrollChallenge
, opt_appId
) {
508 var encodedChallenge
= {};
510 if (enrollChallenge
['version']) {
511 version
= enrollChallenge
['version'];
513 // Version is implicitly V1 if not specified.
516 encodedChallenge
['version'] = version
;
517 encodedChallenge
['challengeHash'] = enrollChallenge
['challenge'];
519 if (enrollChallenge
['appId']) {
520 appId
= enrollChallenge
['appId'];
525 // Sanity check. (Other code should fail if it's not set.)
526 console
.warn(UTIL_fmt('No appId?'));
528 encodedChallenge
['appIdHash'] = B64_encode(sha256HashOfString(appId
));
529 return /** @type {EnrollHelperChallenge} */ (encodedChallenge
);
533 * Encodes the given enroll challenges using this enroller's state.
534 * @param {Array<EnrollChallenge>} enrollChallenges The enroll challenges.
535 * @param {string=} opt_appId The app id for the entire request.
536 * @return {!Array<EnrollHelperChallenge>} The encoded enroll challenges.
539 Enroller
.prototype.encodeEnrollChallenges_ = function(enrollChallenges
,
542 for (var i
= 0; i
< enrollChallenges
.length
; i
++) {
543 var enrollChallenge
= enrollChallenges
[i
];
544 var version
= enrollChallenge
.version
;
546 // Version is implicitly V1 if not specified.
550 if (version
== 'U2F_V2') {
551 var modifiedChallenge
= {};
552 for (var k
in enrollChallenge
) {
553 modifiedChallenge
[k
] = enrollChallenge
[k
];
555 // V2 enroll responses contain signatures over a browser data object,
556 // which we're constructing here. The browser data object contains, among
557 // other things, the server challenge.
558 var serverChallenge
= enrollChallenge
['challenge'];
559 var browserData
= makeEnrollBrowserData(
560 serverChallenge
, this.sender_
.origin
, this.sender_
.tlsChannelId
);
561 // Replace the challenge with the hash of the browser data.
562 modifiedChallenge
['challenge'] =
563 B64_encode(sha256HashOfString(browserData
));
564 this.browserData_
[version
] =
565 B64_encode(UTIL_StringToBytes(browserData
));
566 challenges
.push(Enroller
.encodeEnrollChallenge_(
567 /** @type {EnrollChallenge} */ (modifiedChallenge
), opt_appId
));
570 Enroller
.encodeEnrollChallenge_(enrollChallenge
, opt_appId
));
577 * Checks the app ids associated with this enroll request, and calls a callback
578 * with the result of the check.
579 * @param {!Array<string>} enrollAppIds The app ids in the enroll challenge
580 * portion of the enroll request.
581 * @param {function(boolean)} cb Called with the result of the check.
584 Enroller
.prototype.checkAppIds_ = function(enrollAppIds
, cb
) {
586 UTIL_unionArrays(enrollAppIds
, getDistinctAppIds(this.signChallenges_
));
587 FACTORY_REGISTRY
.getOriginChecker()
588 .canClaimAppIds(this.sender_
.origin
, appIds
)
589 .then(this.originChecked_
.bind(this, appIds
, cb
));
593 * Called with the result of checking the origin. When the origin is allowed
594 * to claim the app ids, begins checking whether the app ids also list the
596 * @param {!Array<string>} appIds The app ids.
597 * @param {function(boolean)} cb Called with the result of the check.
598 * @param {boolean} result Whether the origin could claim the app ids.
601 Enroller
.prototype.originChecked_ = function(appIds
, cb
, result
) {
603 this.notifyError_({errorCode
: ErrorCodes
.BAD_REQUEST
});
606 /** @private {!AppIdChecker} */
607 this.appIdChecker_
= new AppIdChecker(FACTORY_REGISTRY
.getTextFetcher(),
608 this.timer_
.clone(), this.sender_
.origin
, appIds
, this.allowHttp_
,
610 this.appIdChecker_
.doCheck().then(cb
);
613 /** Closes this enroller. */
614 Enroller
.prototype.close = function() {
615 if (this.appIdChecker_
) {
616 this.appIdChecker_
.close();
619 this.handler_
.close();
620 this.handler_
= null;
626 * Notifies the caller with the error.
627 * @param {U2fError} error Error.
630 Enroller
.prototype.notifyError_ = function(error
) {
635 this.errorCb_(error
);
639 * Notifies the caller of success with the provided response data.
640 * @param {string} u2fVersion Protocol version
641 * @param {string} info Response data
642 * @param {string|undefined} opt_browserData Browser data used
645 Enroller
.prototype.notifySuccess_
=
646 function(u2fVersion
, info
, opt_browserData
) {
651 this.successCb_(u2fVersion
, info
, opt_browserData
);
655 * Called by the helper upon completion.
656 * @param {EnrollHelperReply} reply The result of the enroll request.
659 Enroller
.prototype.helperComplete_ = function(reply
) {
661 var reportedError
= mapDeviceStatusCodeToU2fError(reply
.code
);
662 console
.log(UTIL_fmt('helper reported ' + reply
.code
.toString(16) +
663 ', returning ' + reportedError
.errorCode
));
664 this.notifyError_(reportedError
);
666 console
.log(UTIL_fmt('Gnubby enrollment succeeded!!!!!'));
669 if (reply
.version
== 'U2F_V2') {
670 // For U2F_V2, the challenge sent to the gnubby is modified to be the hash
671 // of the browser data. Include the browser data.
672 browserData
= this.browserData_
[reply
.version
];
675 this.notifySuccess_(/** @type {string} */ (reply
.version
),
676 /** @type {string} */ (reply
.enrollData
),