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
),