From ca885e4b153f6121ab10529358065837f5ee8c60 Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Fri, 3 Nov 2017 01:51:00 +0100 Subject: [PATCH] Fix not working u2f when using Firefox The u2f-api package does not use the official u2f script provided by Yubikey. Unfortunately, it was blocked by Firefox. This change reintroduces the official u2f script. --- client/src/index.ts | 5 +- client/src/lib/GetPromised.ts | 14 + client/src/lib/secondfactor/U2FValidator.ts | 79 +- client/src/lib/secondfactor/index.ts | 8 +- client/src/lib/u2f-register/u2f-register.ts | 50 +- client/src/thirdparties/u2f-api.js | 749 ++++++++++++++++++ client/test/secondfactor/U2FValidator.test.ts | 118 --- client/types/u2f-api.d.ts | 114 +-- package-lock.json | 6 - package.json | 1 - server/src/views/secondfactor.pug | 3 +- server/src/views/u2f-register.pug | 3 +- 12 files changed, 908 insertions(+), 242 deletions(-) create mode 100644 client/src/lib/GetPromised.ts create mode 100644 client/src/thirdparties/u2f-api.js delete mode 100644 client/test/secondfactor/U2FValidator.test.ts diff --git a/client/src/index.ts b/client/src/index.ts index 0b4f302e..a7fc54cc 100644 --- a/client/src/index.ts +++ b/client/src/index.ts @@ -9,7 +9,6 @@ import ResetPasswordRequest from "./lib/reset-password/reset-password-request"; import ResetPasswordForm from "./lib/reset-password/reset-password-form"; import jslogger = require("js-logger"); import jQuery = require("jquery"); -import U2fApi = require("u2f-api"); import Endpoints = require("../../shared/api"); jslogger.useDefaults(); @@ -19,11 +18,11 @@ jslogger.setLevel(jslogger.INFO); if (window.location.pathname == Endpoints.FIRST_FACTOR_GET) FirstFactor(window, jQuery, FirstFactorValidator, jslogger); else if (window.location.pathname == Endpoints.SECOND_FACTOR_GET) - SecondFactor(window, jQuery, U2fApi); + SecondFactor(window, jQuery, (global as any).u2f); else if (window.location.pathname == Endpoints.SECOND_FACTOR_TOTP_IDENTITY_FINISH_GET) TOTPRegister(window, jQuery); else if (window.location.pathname == Endpoints.SECOND_FACTOR_U2F_IDENTITY_FINISH_GET) - U2fRegister(window, jQuery); + U2fRegister(window, jQuery, (global as any).u2f); else if (window.location.pathname == Endpoints.RESET_PASSWORD_IDENTITY_FINISH_GET) ResetPasswordForm(window, jQuery); else if (window.location.pathname == Endpoints.RESET_PASSWORD_REQUEST_GET) diff --git a/client/src/lib/GetPromised.ts b/client/src/lib/GetPromised.ts new file mode 100644 index 00000000..a03036e5 --- /dev/null +++ b/client/src/lib/GetPromised.ts @@ -0,0 +1,14 @@ +import BluebirdPromise = require("bluebird"); + +export default function ($: JQueryStatic, url: string, data: Object, fn: any, + dataType: string): BluebirdPromise { + return new BluebirdPromise(function (resolve, reject) { + $.get(url, {}, undefined, dataType) + .done(function (data: any) { + resolve(data); + }) + .fail(function (err: Error) { + reject(err); + }); + }); +} \ No newline at end of file diff --git a/client/src/lib/secondfactor/U2FValidator.ts b/client/src/lib/secondfactor/U2FValidator.ts index 1fc185e9..e24122ed 100644 --- a/client/src/lib/secondfactor/U2FValidator.ts +++ b/client/src/lib/secondfactor/U2FValidator.ts @@ -1,6 +1,5 @@ - -import U2fApi = require("u2f-api"); import U2f = require("u2f"); +import U2fApi = require("../../../types/u2f-api"); import BluebirdPromise = require("bluebird"); import { SignMessage } from "../../../../shared/SignMessage"; import Endpoints = require("../../../../shared/api"); @@ -8,8 +7,10 @@ import UserMessages = require("../../../../shared/UserMessages"); import { INotifier } from "../INotifier"; import { RedirectionMessage } from "../../../../shared/RedirectionMessage"; import { ErrorMessage } from "../../../../shared/ErrorMessage"; +import GetPromised from "../GetPromised"; -function finishU2fAuthentication(responseData: U2fApi.SignResponse, $: JQueryStatic): BluebirdPromise { +function finishU2fAuthentication(responseData: U2fApi.SignResponse, + $: JQueryStatic): BluebirdPromise { return new BluebirdPromise(function (resolve, reject) { $.ajax({ url: Endpoints.SECOND_FACTOR_U2F_SIGN_POST, @@ -30,40 +31,50 @@ function finishU2fAuthentication(responseData: U2fApi.SignResponse, $: JQuerySta }); } -function startU2fAuthentication($: JQueryStatic, notifier: INotifier, u2fApi: typeof U2fApi): BluebirdPromise { - return new BluebirdPromise(function (resolve, reject) { - $.get(Endpoints.SECOND_FACTOR_U2F_SIGN_REQUEST_GET, {}, undefined, "json") - .done(function (signResponse: SignMessage) { - notifier.info(UserMessages.PLEASE_TOUCH_TOKEN); +function u2fApiSign(u2fApi: U2fApi.U2fApi, appId: string, challenge: string, + registeredKey: U2fApi.RegisteredKey, timeout: number) + : BluebirdPromise { - const signRequest: U2fApi.SignRequest = { - appId: signResponse.request.appId, - challenge: signResponse.request.challenge, - keyHandle: signResponse.keyHandle, // linked to the client session cookie - version: "U2F_V2" - }; - - u2fApi.sign([signRequest], 60) - .then(function (signResponse: U2fApi.SignResponse) { - finishU2fAuthentication(signResponse, $) - .then(function (redirect: string) { - resolve(redirect); - }, function (err) { - notifier.error(UserMessages.U2F_TRANSACTION_FINISH_FAILED); - reject(err); - }); - }) - .catch(function (err: Error) { - reject(err); - }); - }) - .fail(function (xhr: JQueryXHR, textStatus: string) { - reject(new Error(textStatus)); - }); + return new BluebirdPromise(function (resolve, reject) { + u2fApi.sign(appId, challenge, [registeredKey], + function (signResponse: U2fApi.SignResponse | U2fApi.Error) { + if ("errorCode" in signResponse) { + reject(new Error((signResponse as U2fApi.Error).errorMessage)); + return; + } + resolve(signResponse as U2fApi.SignResponse); + }, timeout); }); } +function startU2fAuthentication($: JQueryStatic, notifier: INotifier, + u2fApi: U2fApi.U2fApi): BluebirdPromise { -export function validate($: JQueryStatic, notifier: INotifier, u2fApi: typeof U2fApi): BluebirdPromise { - return startU2fAuthentication($, notifier, u2fApi); + return GetPromised($, Endpoints.SECOND_FACTOR_U2F_SIGN_REQUEST_GET, {}, + undefined, "json") + .then(function (signResponse: SignMessage) { + notifier.info(UserMessages.PLEASE_TOUCH_TOKEN); + + const registeredKey: U2fApi.RegisteredKey = { + keyHandle: signResponse.keyHandle, + version: "U2F_V2", + appId: signResponse.request.appId + }; + + return u2fApiSign(u2fApi, signResponse.request.appId, + signResponse.request.challenge, registeredKey, 60); + }) + .then(function (signResponse: U2fApi.SignResponse) { + return finishU2fAuthentication(signResponse, $); + }); +} + + +export function validate($: JQueryStatic, notifier: INotifier, + u2fApi: U2fApi.U2fApi) { + return startU2fAuthentication($, notifier, u2fApi) + .catch(function (err: Error) { + notifier.error(UserMessages.U2F_TRANSACTION_FINISH_FAILED); + return BluebirdPromise.reject(err); + }); } diff --git a/client/src/lib/secondfactor/index.ts b/client/src/lib/secondfactor/index.ts index db58cfff..93e474db 100644 --- a/client/src/lib/secondfactor/index.ts +++ b/client/src/lib/secondfactor/index.ts @@ -1,7 +1,5 @@ - -import U2fApi = require("u2f-api"); import jslogger = require("js-logger"); - +import U2fApi = require("../../../types/u2f-api"); import TOTPValidator = require("./TOTPValidator"); import U2FValidator = require("./U2FValidator"); import ClientConstants = require("./constants"); @@ -12,7 +10,7 @@ import ServerConstants = require("../../../../shared/constants"); import UserMessages = require("../../../../shared/UserMessages"); import SharedConstants = require("../../../../shared/constants"); -export default function (window: Window, $: JQueryStatic, u2fApi: typeof U2fApi) { +export default function (window: Window, $: JQueryStatic, u2fApi: U2fApi.U2fApi) { const notifierTotp = new Notifier(".notification-totp", $); const notifierU2f = new Notifier(".notification-u2f", $); @@ -51,7 +49,7 @@ export default function (window: Window, $: JQueryStatic, u2fApi: typeof U2fApi) $(window.document).ready(function () { $(ClientConstants.TOTP_FORM_SELECTOR).on("submit", onTOTPFormSubmitted); - U2FValidator.validate($, notifierU2f, U2fApi) + U2FValidator.validate($, notifierU2f, u2fApi) .then(onU2fAuthenticationSuccess, onU2fAuthenticationFailure); }); } \ No newline at end of file diff --git a/client/src/lib/u2f-register/u2f-register.ts b/client/src/lib/u2f-register/u2f-register.ts index 7c36094d..f996a808 100644 --- a/client/src/lib/u2f-register/u2f-register.ts +++ b/client/src/lib/u2f-register/u2f-register.ts @@ -1,22 +1,21 @@ import BluebirdPromise = require("bluebird"); import U2f = require("u2f"); -import u2fApi = require("u2f-api"); +import U2fApi = require("u2f-api"); import jslogger = require("js-logger"); import { Notifier } from "../Notifier"; +import GetPromised from "../GetPromised"; import Endpoints = require("../../../../shared/api"); import UserMessages = require("../../../../shared/UserMessages"); import { RedirectionMessage } from "../../../../shared/RedirectionMessage"; import { ErrorMessage } from "../../../../shared/ErrorMessage"; -export default function (window: Window, $: JQueryStatic) { +export default function (window: Window, $: JQueryStatic, u2fApi: U2fApi.U2fApi) { const notifier = new Notifier(".notification", $); - function checkRegistration(regResponse: u2fApi.RegisterResponse): BluebirdPromise { + function checkRegistration(regResponse: U2fApi.RegisterResponse): BluebirdPromise { const registrationData: U2f.RegistrationData = regResponse; - jslogger.debug("registrationResponse = %s", JSON.stringify(registrationData)); - return new BluebirdPromise(function (resolve, reject) { $.post(Endpoints.SECOND_FACTOR_U2F_REGISTER_POST, registrationData, undefined, "json") .done(function (body: RedirectionMessage | ErrorMessage) { @@ -32,25 +31,34 @@ export default function (window: Window, $: JQueryStatic) { }); } - function requestRegistration(): BluebirdPromise { - return new BluebirdPromise(function (resolve, reject) { - $.get(Endpoints.SECOND_FACTOR_U2F_REGISTER_REQUEST_GET, {}, undefined, "json") - .done(function (registrationRequest: U2f.Request) { - const registerRequest: u2fApi.RegisterRequest = registrationRequest; - u2fApi.register([registerRequest], [], 120) - .then(function (res: u2fApi.RegisterResponse) { - return checkRegistration(res); - }) - .then(function (redirectionUrl: string) { - resolve(redirectionUrl); - }) - .catch(function (err: Error) { - reject(err); - }); - }); + function u2fApiRegister(u2fApi: U2fApi.U2fApi, appId: string, + registerRequest: U2fApi.RegisterRequest, timeout: number) { + + return new BluebirdPromise(function (resolve, reject) { + u2fApi.register(appId, [registerRequest], [], + function (res: U2fApi.RegisterResponse | U2fApi.Error) { + if ("errorCode" in res) { + reject(new Error((res as U2fApi.Error).errorMessage)); + return; + } + resolve(res); + }, timeout); }); } + function requestRegistration(): BluebirdPromise { + return GetPromised($, Endpoints.SECOND_FACTOR_U2F_REGISTER_REQUEST_GET, {}, + undefined, "json") + .then(function (registrationRequest: U2f.Request) { + const registerRequest: U2fApi.RegisterRequest = registrationRequest; + const appId = registrationRequest.appId; + return u2fApiRegister(u2fApi, appId, registerRequest, 60); + }) + .then(function (res: U2fApi.RegisterResponse) { + return checkRegistration(res); + }); + } + function onRegisterFailure(err: Error) { notifier.error(UserMessages.REGISTRATION_U2F_FAILED); } diff --git a/client/src/thirdparties/u2f-api.js b/client/src/thirdparties/u2f-api.js new file mode 100644 index 00000000..8c7801e3 --- /dev/null +++ b/client/src/thirdparties/u2f-api.js @@ -0,0 +1,749 @@ +//Copyright 2014-2015 Google Inc. All rights reserved. + +//Use of this source code is governed by a BSD-style +//license that can be found in the LICENSE file or at +//https://developers.google.com/open-source/licenses/bsd + +/** + * @fileoverview The U2F api. + */ +'use strict'; + + +/** + * Namespace for the U2F api. + * @type {Object} + */ +var u2f = u2f || {}; + +/** + * FIDO U2F Javascript API Version + * @number + */ +var js_api_version; + +/** + * The U2F extension id + * @const {string} + */ +// The Chrome packaged app extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the package Chrome app and does not require installing the U2F Chrome extension. + u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; +// The U2F Chrome extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the U2F Chrome extension to authenticate. +// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne'; + + +/** + * Message types for messsages to/from the extension + * @const + * @enum {string} + */ +u2f.MessageTypes = { + 'U2F_REGISTER_REQUEST': 'u2f_register_request', + 'U2F_REGISTER_RESPONSE': 'u2f_register_response', + 'U2F_SIGN_REQUEST': 'u2f_sign_request', + 'U2F_SIGN_RESPONSE': 'u2f_sign_response', + 'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request', + 'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response' +}; + + +/** + * Response status codes + * @const + * @enum {number} + */ +u2f.ErrorCodes = { + 'OK': 0, + 'OTHER_ERROR': 1, + 'BAD_REQUEST': 2, + 'CONFIGURATION_UNSUPPORTED': 3, + 'DEVICE_INELIGIBLE': 4, + 'TIMEOUT': 5 +}; + + +/** + * A message for registration requests + * @typedef {{ + * type: u2f.MessageTypes, + * appId: ?string, + * timeoutSeconds: ?number, + * requestId: ?number + * }} + */ +u2f.U2fRequest; + + +/** + * A message for registration responses + * @typedef {{ + * type: u2f.MessageTypes, + * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), + * requestId: ?number + * }} + */ +u2f.U2fResponse; + + +/** + * An error object for responses + * @typedef {{ + * errorCode: u2f.ErrorCodes, + * errorMessage: ?string + * }} + */ +u2f.Error; + +/** + * Data object for a single sign request. + * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC}} + */ +u2f.Transport; + + +/** + * Data object for a single sign request. + * @typedef {Array} + */ +u2f.Transports; + +/** + * Data object for a single sign request. + * @typedef {{ + * version: string, + * challenge: string, + * keyHandle: string, + * appId: string + * }} + */ +u2f.SignRequest; + + +/** + * Data object for a sign response. + * @typedef {{ + * keyHandle: string, + * signatureData: string, + * clientData: string + * }} + */ +u2f.SignResponse; + + +/** + * Data object for a registration request. + * @typedef {{ + * version: string, + * challenge: string + * }} + */ +u2f.RegisterRequest; + + +/** + * Data object for a registration response. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: Transports, + * appId: string + * }} + */ +u2f.RegisterResponse; + + +/** + * Data object for a registered key. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: ?Transports, + * appId: ?string + * }} + */ +u2f.RegisteredKey; + + +/** + * Data object for a get API register response. + * @typedef {{ + * js_api_version: number + * }} + */ +u2f.GetJsApiVersionResponse; + + +//Low level MessagePort API support + +/** + * Sets up a MessagePort to the U2F extension using the + * available mechanisms. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + */ +u2f.getMessagePort = function(callback) { + if (typeof chrome != 'undefined' && chrome.runtime) { + // The actual message here does not matter, but we need to get a reply + // for the callback to run. Thus, send an empty signature request + // in order to get a failure response. + var msg = { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: [] + }; + chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { + if (!chrome.runtime.lastError) { + // We are on a whitelisted origin and can talk directly + // with the extension. + u2f.getChromeRuntimePort_(callback); + } else { + // chrome.runtime was available, but we couldn't message + // the extension directly, use iframe + u2f.getIframePort_(callback); + } + }); + } else if (u2f.isAndroidChrome_()) { + u2f.getAuthenticatorPort_(callback); + } else if (u2f.isIosChrome_()) { + u2f.getIosPort_(callback); + } else { + // chrome.runtime was not available at all, which is normal + // when this origin doesn't have access to any extensions. + u2f.getIframePort_(callback); + } +}; + +/** + * Detect chrome running on android based on the browser's useragent. + * @private + */ +u2f.isAndroidChrome_ = function() { + var userAgent = navigator.userAgent; + return userAgent.indexOf('Chrome') != -1 && + userAgent.indexOf('Android') != -1; +}; + +/** + * Detect chrome running on iOS based on the browser's platform. + * @private + */ +u2f.isIosChrome_ = function() { + return ["iPhone", "iPad", "iPod"].indexOf(navigator.platform) > -1; +}; + +/** + * Connects directly to the extension via chrome.runtime.connect. + * @param {function(u2f.WrappedChromeRuntimePort_)} callback + * @private + */ +u2f.getChromeRuntimePort_ = function(callback) { + var port = chrome.runtime.connect(u2f.EXTENSION_ID, + {'includeTlsChannelId': true}); + setTimeout(function() { + callback(new u2f.WrappedChromeRuntimePort_(port)); + }, 0); +}; + +/** + * Return a 'port' abstraction to the Authenticator app. + * @param {function(u2f.WrappedAuthenticatorPort_)} callback + * @private + */ +u2f.getAuthenticatorPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedAuthenticatorPort_()); + }, 0); +}; + +/** + * Return a 'port' abstraction to the iOS client app. + * @param {function(u2f.WrappedIosPort_)} callback + * @private + */ +u2f.getIosPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedIosPort_()); + }, 0); +}; + +/** + * A wrapper for chrome.runtime.Port that is compatible with MessagePort. + * @param {Port} port + * @constructor + * @private + */ +u2f.WrappedChromeRuntimePort_ = function(port) { + this.port_ = port; +}; + +/** + * Format and return a sign request compliant with the JS API version supported by the extension. + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatSignRequest_ = + function(appId, challenge, registeredKeys, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: challenge, + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: signRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + appId: appId, + challenge: challenge, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + +/** + * Format and return a register request compliant with the JS API version supported by the extension.. + * @param {Array} signRequests + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatRegisterRequest_ = + function(appId, registeredKeys, registerRequests, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + for (var i = 0; i < registerRequests.length; i++) { + registerRequests[i].appId = appId; + } + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: registerRequests[0], + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + signRequests: signRequests, + registerRequests: registerRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + appId: appId, + registerRequests: registerRequests, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + + +/** + * Posts a message on the underlying channel. + * @param {Object} message + */ +u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { + this.port_.postMessage(message); +}; + + +/** + * Emulates the HTML 5 addEventListener interface. Works only for the + * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedChromeRuntimePort_.prototype.addEventListener = + function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message' || name == 'onmessage') { + this.port_.onMessage.addListener(function(message) { + // Emulate a minimal MessageEvent object + handler({'data': message}); + }); + } else { + console.error('WrappedChromeRuntimePort only supports onMessage'); + } +}; + +/** + * Wrap the Authenticator app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedAuthenticatorPort_ = function() { + this.requestId_ = -1; + this.requestObject_ = null; +} + +/** + * Launch the Authenticator intent. + * @param {Object} message + */ +u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.request=' + encodeURIComponent(JSON.stringify(message)) + + ';end'; + document.location = intentUrl; +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() { + return "WrappedAuthenticatorPort_"; +}; + + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message') { + var self = this; + /* Register a callback to that executes when + * chrome injects the response. */ + window.addEventListener( + 'message', self.onRequestUpdate_.bind(self, handler), false); + } else { + console.error('WrappedAuthenticatorPort only supports message'); + } +}; + +/** + * Callback invoked when a response is received from the Authenticator. + * @param function({data: Object}) callback + * @param {Object} message message Object + */ +u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = + function(callback, message) { + var messageObject = JSON.parse(message.data); + var intentUrl = messageObject['intentURL']; + + var errorCode = messageObject['errorCode']; + var responseObject = null; + if (messageObject.hasOwnProperty('data')) { + responseObject = /** @type {Object} */ ( + JSON.parse(messageObject['data'])); + } + + callback({'data': responseObject}); +}; + +/** + * Base URL for intents to Authenticator. + * @const + * @private + */ +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = + 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; + +/** + * Wrap the iOS client app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedIosPort_ = function() {}; + +/** + * Launch the iOS client app request + * @param {Object} message + */ +u2f.WrappedIosPort_.prototype.postMessage = function(message) { + var str = JSON.stringify(message); + var url = "u2f://auth?" + encodeURI(str); + location.replace(url); +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedIosPort_.prototype.getPortType = function() { + return "WrappedIosPort_"; +}; + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name !== 'message') { + console.error('WrappedIosPort only supports message'); + } +}; + +/** + * Sets up an embedded trampoline iframe, sourced from the extension. + * @param {function(MessagePort)} callback + * @private + */ +u2f.getIframePort_ = function(callback) { + // Create the iframe + var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; + var iframe = document.createElement('iframe'); + iframe.src = iframeOrigin + '/u2f-comms.html'; + iframe.setAttribute('style', 'display:none'); + document.body.appendChild(iframe); + + var channel = new MessageChannel(); + var ready = function(message) { + if (message.data == 'ready') { + channel.port1.removeEventListener('message', ready); + callback(channel.port1); + } else { + console.error('First event on iframe port was not "ready"'); + } + }; + channel.port1.addEventListener('message', ready); + channel.port1.start(); + + iframe.addEventListener('load', function() { + // Deliver the port to the iframe and initialize + iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); + }); +}; + + +//High-level JS API + +/** + * Default extension response timeout in seconds. + * @const + */ +u2f.EXTENSION_TIMEOUT_SEC = 30; + +/** + * A singleton instance for a MessagePort to the extension. + * @type {MessagePort|u2f.WrappedChromeRuntimePort_} + * @private + */ +u2f.port_ = null; + +/** + * Callbacks waiting for a port + * @type {Array} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.} + * @private + */ +u2f.callbackMap_ = {}; + +/** + * Creates or retrieves the MessagePort singleton to use. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + * @private + */ +u2f.getPortSingleton_ = function(callback) { + if (u2f.port_) { + callback(u2f.port_); + } else { + if (u2f.waitingForPort_.length == 0) { + u2f.getMessagePort(function(port) { + u2f.port_ = port; + u2f.port_.addEventListener('message', + /** @type {function(Event)} */ (u2f.responseHandler_)); + + // Careful, here be async callbacks. Maybe. + while (u2f.waitingForPort_.length) + u2f.waitingForPort_.shift()(u2f.port_); + }); + } + u2f.waitingForPort_.push(callback); + } +}; + +/** + * Handles response messages from the extension. + * @param {MessageEvent.} message + * @private + */ +u2f.responseHandler_ = function(message) { + var response = message.data; + var reqId = response['requestId']; + if (!reqId || !u2f.callbackMap_[reqId]) { + console.error('Unknown or missing requestId in response.'); + return; + } + var cb = u2f.callbackMap_[reqId]; + delete u2f.callbackMap_[reqId]; + cb(response['responseData']); +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the sign request. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sign = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual sign request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual sign request in the supported API version. + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendSignRequest = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the register request. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.register = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual register request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0: response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual register request in the supported API version. + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendRegisterRequest = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatRegisterRequest_( + appId, registeredKeys, registerRequests, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + + +/** + * Dispatches a message to the extension to find out the supported + * JS API version. + * If the user is on a mobile phone and is thus using Google Authenticator instead + * of the Chrome extension, don't send the request and simply return 0. + * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.getApiVersion = function(callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + // If we are using Android Google Authenticator or iOS client app, + // do not fire an intent to ask which JS API version to use. + if (port.getPortType) { + var apiVersion; + switch (port.getPortType()) { + case 'WrappedIosPort_': + case 'WrappedAuthenticatorPort_': + apiVersion = 1.1; + break; + + default: + apiVersion = 0; + break; + } + callback({ 'js_api_version': apiVersion }); + return; + } + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var req = { + type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, + timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), + requestId: reqId + }; + port.postMessage(req); + }); +}; + diff --git a/client/test/secondfactor/U2FValidator.test.ts b/client/test/secondfactor/U2FValidator.test.ts deleted file mode 100644 index 4c3fdef3..00000000 --- a/client/test/secondfactor/U2FValidator.test.ts +++ /dev/null @@ -1,118 +0,0 @@ - -import U2FValidator = require("../../src/lib/secondfactor/U2FValidator"); -import { INotifier } from "../../src/lib/INotifier"; -import JQueryMock = require("../mocks/jquery"); -import U2FApiMock = require("../mocks/u2f-api"); -import { SignMessage } from "../../../shared/SignMessage"; -import BluebirdPromise = require("bluebird"); -import Assert = require("assert"); -import { NotifierStub } from "../mocks/NotifierStub"; - -describe("test U2F validation", function () { - let notifier: INotifier; - - beforeEach(function() { - notifier = new NotifierStub(); - }); - - it("should validate the U2F device", () => { - const signatureRequest: SignMessage = { - keyHandle: "keyhandle", - request: { - version: "U2F_V2", - appId: "https://example.com", - challenge: "challenge" - } - }; - const u2fClient = U2FApiMock.U2FApiMock(); - u2fClient.sign.returns(BluebirdPromise.resolve()); - - const getPromise = JQueryMock.JQueryDeferredMock(); - getPromise.done.yields(signatureRequest); - getPromise.done.returns(getPromise); - - const postPromise = JQueryMock.JQueryDeferredMock(); - postPromise.done.yields({ redirect: "https://home.test.url" }); - postPromise.done.returns(postPromise); - - const jqueryMock = JQueryMock.JQueryMock(); - jqueryMock.jquery.get.returns(getPromise); - jqueryMock.jquery.ajax.returns(postPromise); - - return U2FValidator.validate(jqueryMock.jquery as any, notifier, u2fClient as any); - }); - - it("should fail during initial authentication request", () => { - const u2fClient = U2FApiMock.U2FApiMock(); - - const getPromise = JQueryMock.JQueryDeferredMock(); - getPromise.done.returns(getPromise); - getPromise.fail.yields(undefined, "Error while issuing authentication request"); - - const jqueryMock = JQueryMock.JQueryMock(); - jqueryMock.jquery.get.returns(getPromise); - - return U2FValidator.validate(jqueryMock.jquery as any, notifier, u2fClient as any) - .catch(function(err: Error) { - Assert.equal("Error while issuing authentication request", err.message); - return BluebirdPromise.resolve(); - }); - }); - - it("should fail during device signature", () => { - const signatureRequest: SignMessage = { - keyHandle: "keyhandle", - request: { - version: "U2F_V2", - appId: "https://example.com", - challenge: "challenge" - } - }; - const u2fClient = U2FApiMock.U2FApiMock(); - u2fClient.sign.returns(BluebirdPromise.reject(new Error("Device unable to sign"))); - - const getPromise = JQueryMock.JQueryDeferredMock(); - getPromise.done.yields(signatureRequest); - getPromise.done.returns(getPromise); - - const jqueryMock = JQueryMock.JQueryMock(); - jqueryMock.jquery.get.returns(getPromise); - - return U2FValidator.validate(jqueryMock.jquery as any, notifier, u2fClient as any) - .catch(function(err: Error) { - Assert.equal("Device unable to sign", err.message); - return BluebirdPromise.resolve(); - }); - }); - - it("should fail at the end of the authentication request", () => { - const signatureRequest: SignMessage = { - keyHandle: "keyhandle", - request: { - version: "U2F_V2", - appId: "https://example.com", - challenge: "challenge" - } - }; - const u2fClient = U2FApiMock.U2FApiMock(); - u2fClient.sign.returns(BluebirdPromise.resolve()); - - const getPromise = JQueryMock.JQueryDeferredMock(); - getPromise.done.yields(signatureRequest); - getPromise.done.returns(getPromise); - - const postPromise = JQueryMock.JQueryDeferredMock(); - postPromise.fail.yields(undefined, "Error while finishing authentication"); - postPromise.done.returns(postPromise); - - const jqueryMock = JQueryMock.JQueryMock(); - jqueryMock.jquery.get.returns(getPromise); - jqueryMock.jquery.ajax.returns(postPromise); - - return U2FValidator.validate(jqueryMock.jquery as any, notifier, u2fClient as any) - .catch(function(err: Error) { - Assert.equal("Error while finishing authentication", err.message); - return BluebirdPromise.resolve(); - }); - }); -}); \ No newline at end of file diff --git a/client/types/u2f-api.d.ts b/client/types/u2f-api.d.ts index 87a0e4b8..7675ca23 100644 --- a/client/types/u2f-api.d.ts +++ b/client/types/u2f-api.d.ts @@ -1,63 +1,73 @@ +type MessageTypes = "u2f_register_request" | "u2f_sign_request" | + "u2f_register_response" | "u2f_sign_response"; + +export interface Request { + type: MessageTypes; + signRequests: SignRequest[]; + registerRequests?: RegisterRequest[]; + timeoutSeconds?: number; + requestId?: number; +} + +type ResponseData = Error | RegisterResponse | SignResponse; -declare module "u2f-api" { - type MessageTypes = "u2f_register_request" | "u2f_sign_request" | "u2f_register_response" | "u2f_sign_response"; +export interface Response { + type: MessageTypes; + responseData: ResponseData; + requestId?: number; +} - export interface Request { - type: MessageTypes, - signRequests: SignRequest[], - registerRequests?: RegisterRequest[], - timeoutSeconds?: number, - requestId?: number - } +export enum ErrorCodes { + "OK" = 0, + "OTHER_ERROR" = 1, + "BAD_REQUEST" = 2, + "CONFIGURATION_UNSUPPORTED" = 3, + "DEVICE_INELIGIBLE" = 4, + "TIMEOUT" = 5 +} - type ResponseData = Error | RegisterResponse | SignResponse; +export interface Error { + errorCode: ErrorCodes; + errorMessage?: string; +} +export interface RegisterResponse { + registrationData: string; + clientData: string; +} - export interface Response { - type: MessageTypes; - responseData: ResponseData; - requestId?: number; - } +export interface RegisterRequest { + version: string; + challenge: string; +} - export enum ErrorCodes { - 'OK' = 0, - 'OTHER_ERROR' = 1, - 'BAD_REQUEST' = 2, - 'CONFIGURATION_UNSUPPORTED' = 3, - 'DEVICE_INELIGIBLE' = 4, - 'TIMEOUT' = 5 - } +export interface SignResponse { + keyHandle: string; + signatureData: string; + clientData: string; +} - export interface Error { - errorCode: ErrorCodes; - errorMessage?: string; - } +export interface SignRequest { + version: string; + challenge: string; + keyHandle: string; + appId: string; +} - export interface RegisterResponse { - registrationData: string; - clientData: string; - } +export interface RegisteredKey { + version: string, + keyHandle: string, + transports?: any, + appId?: string +} - export interface RegisterRequest { - version: string; - challenge: string; - appId: string; - } +export interface U2fApi { + sign(appId: string, challenge: string, registeredKeys: RegisteredKey[], + cb: (res: SignResponse | Error) => void, timeout: number): void; - export interface SignResponse { - keyHandle: string; - signatureData: string; - clientData: string; - } - - export interface SignRequest { - version: string; - challenge: string; - keyHandle: string; - appId: string; - } - - export function sign(signRequests: SignRequest[], timeout: number): Promise; - export function register(registerRequests: RegisterRequest[], signRequests: SignRequest[], timeout: number): Promise; -} \ No newline at end of file + register(appId: string, registerRequests: RegisterRequest[], + registeredKeys: RegisteredKey[], + cb: (res: RegisterResponse | Error) => void, + timeout: number): void; +} diff --git a/package-lock.json b/package-lock.json index 337bfa05..6b40aec7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8723,12 +8723,6 @@ "resolved": "https://registry.npmjs.org/u2f/-/u2f-0.1.3.tgz", "integrity": "sha512-/IaxeBqjo5o3D7plPkxdApbCpgGoI2bmTomS1kq5OjVflaE9UBJ0WfqoXqZryZKfFYBjQC7Tn1hA57WtRgh/Sg==" }, - "u2f-api": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/u2f-api/-/u2f-api-0.0.9.tgz", - "integrity": "sha1-ve1Fe6Wpqe6bWLPeKR+MH4/qy3o=", - "dev": true - }, "uc.micro": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.3.tgz", diff --git a/package.json b/package.json index 1b05cf3e..7768f9c5 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,6 @@ "tslint": "^5.2.0", "typescript": "^2.3.2", "typescript-json-schema": "^0.17.0", - "u2f-api": "0.0.9", "uglify-es": "^3.0.15" }, "nyc": { diff --git a/server/src/views/secondfactor.pug b/server/src/views/secondfactor.pug index aaabeccb..00053a59 100644 --- a/server/src/views/secondfactor.pug +++ b/server/src/views/secondfactor.pug @@ -27,4 +27,5 @@ block content a(href=u2f_identity_start_endpoint, class="link register-u2f") U2F | | a(href=totp_identity_start_endpoint, class="link register-totp") Google Authenticator - span(class="clearfix") \ No newline at end of file + span(class="clearfix") + script(src="/js/u2f-api.js", type="text/javascript") \ No newline at end of file diff --git a/server/src/views/u2f-register.pug b/server/src/views/u2f-register.pug index 5e24bc70..d52eba6c 100644 --- a/server/src/views/u2f-register.pug +++ b/server/src/views/u2f-register.pug @@ -8,4 +8,5 @@ block form-header block content p Touch the token to register your security key. - img(src="/img/pendrive.png" alt="pendrive") \ No newline at end of file + img(src="/img/pendrive.png" alt="pendrive") + script(src="/js/u2f-api.js", type="text/javascript") \ No newline at end of file