From 14f6602b3e9f32f6f4a09dd429c240d3cc87a0f4 Mon Sep 17 00:00:00 2001 From: William Barkoff Date: Tue, 11 May 2021 18:20:33 -0400 Subject: [PATCH] security: begin support for WebAuthn See: MyHomeworkSpace/client#153 --- app/api.js | 22 +++ app/auth/SecurityKeyPrompt.jsx | 10 ++ app/base64.js | 154 +++++++++++++++++++ app/settings/panes/account/TwoFactorInfo.jsx | 25 ++- app/settings/panes/account/WebAuthnModal.jsx | 112 ++++++++++++++ app/ui/LoadingIndicator.jsx | 2 +- app/ui/ModalManager.jsx | 2 + 7 files changed, 322 insertions(+), 5 deletions(-) create mode 100644 app/auth/SecurityKeyPrompt.jsx create mode 100644 app/base64.js create mode 100644 app/settings/panes/account/WebAuthnModal.jsx diff --git a/app/api.js b/app/api.js index 1731ae6..8a5979c 100644 --- a/app/api.js +++ b/app/api.js @@ -64,6 +64,24 @@ var rawRequest = function(path, method, data, callback) { request.send(method == "POST" ? paramStr : undefined); }; +var jsonRequest = function(path, method, data, callback) { + var request = new XMLHttpRequest(); + + request.withCredentials = true; + request.open(method, buildURL(path, method, data), true); + request.onload = function() { + callback(JSON.parse(request.responseText), request); + }; + request.onerror = function() { + callback({ + status: "error", + error: "disconnected" + }, request); + }; + request.setRequestHeader("Content-Type", "application/json; charset=UTF-8"); + request.send(data); +}; + export default { get: function(path, data, callback) { return rawRequest(path, "GET", data, callback); @@ -71,6 +89,10 @@ export default { post: function(path, data, callback) { return rawRequest(path, "POST", data, callback); }, + // Used only for _special_ requests + postJSON: function(path, data, callback) { + return jsonRequest(path, "POST", data, callback); + }, init: function(callback) { rawRequest("auth/csrf", "GET", {}, function(data) { csrfToken = data.token; diff --git a/app/auth/SecurityKeyPrompt.jsx b/app/auth/SecurityKeyPrompt.jsx new file mode 100644 index 0000000..4cea2bb --- /dev/null +++ b/app/auth/SecurityKeyPrompt.jsx @@ -0,0 +1,10 @@ +import { h } from "preact"; +import LoadingIndicator from "ui/LoadingIndicator.jsx"; + +export default function SecurityKeyPrompt() { + return
+ +

Authenticate now

+

Insert and touch your security key to authenticate.

+
; +} \ No newline at end of file diff --git a/app/base64.js b/app/base64.js new file mode 100644 index 0000000..e6ebbe2 --- /dev/null +++ b/app/base64.js @@ -0,0 +1,154 @@ +/** +The code in this file was modified from https://github.com/duo-labs/webauthn.io, +released under the following license. + +The following modifications were made: +- Converted to JavaScript module +- Changed "let" to "let" and "const" in several places +- Adjusted formatting to conform with MyHomeworkSpace styles ("npm run lint:fix") + +--- + +BSD 3-Clause License + +Copyright (c) 2019, Duo Labs +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +const lookup = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + +const Arr = (typeof Uint8Array !== "undefined") + ? Uint8Array + : Array; + +const PLUS = "+".charCodeAt(0); +const SLASH = "/".charCodeAt(0); +const NUMBER = "0".charCodeAt(0); +const LOWER = "a".charCodeAt(0); +const UPPER = "A".charCodeAt(0); +const PLUS_URL_SAFE = "-".charCodeAt(0); +const SLASH_URL_SAFE = "_".charCodeAt(0); + +function decode(elt) { + const code = elt.charCodeAt(0); + if (code === PLUS || code === PLUS_URL_SAFE) return 62; // '+' + if (code === SLASH || code === SLASH_URL_SAFE) return 63; // '/' + if (code < NUMBER) return -1; // no match + if (code < NUMBER + 10) return code - NUMBER + 26 + 26; + if (code < UPPER + 26) return code - UPPER; + if (code < LOWER + 26) return code - LOWER + 26; +} + +export function base64ToByteArray(b64) { + let i, j, l, tmp, placeHolders, arr; + + if (b64.length % 4 > 0) { + throw new Error("Invalid string. Length must be a multiple of 4"); + } + + // the number of equal signs (place holders) + // if there are two placeholders, than the two characters before it + // represent one byte + // if there is only one, then the three characters before it represent 2 bytes + // this is just a cheap hack to not do indexOf twice + let len = b64.length; + placeHolders = b64.charAt(len - 2) === "=" ? 2 : b64.charAt(len - 1) === "=" ? 1 : 0; + + // base64 is 4/3 + up to two characters of the original data + arr = new Arr(b64.length * 3 / 4 - placeHolders); + + // if there are placeholders, only get up to the last complete 4 chars + l = placeHolders > 0 ? b64.length - 4 : b64.length; + + let L = 0; + + function push(v) { + arr[L++] = v; + } + + for (i = 0, j = 0; i < l; i += 4, j += 3) { + tmp = (decode(b64.charAt(i)) << 18) | (decode(b64.charAt(i + 1)) << 12) | (decode(b64.charAt(i + 2)) << 6) | decode(b64.charAt(i + 3)); + push((tmp & 0xFF0000) >> 16); + push((tmp & 0xFF00) >> 8); + push(tmp & 0xFF); + } + + if (placeHolders === 2) { + tmp = (decode(b64.charAt(i)) << 2) | (decode(b64.charAt(i + 1)) >> 4); + push(tmp & 0xFF); + } else if (placeHolders === 1) { + tmp = (decode(b64.charAt(i)) << 10) | (decode(b64.charAt(i + 1)) << 4) | (decode(b64.charAt(i + 2)) >> 2); + push((tmp >> 8) & 0xFF); + push(tmp & 0xFF); + } + + return arr; +} + +export function uint8ToBase64(uint8) { + let i; + let extraBytes = uint8.length % 3; // if we have 1 byte left, pad 2 bytes + let output = ""; + let temp, length; + + function encode(num) { + return lookup.charAt(num); + } + + function tripletToBase64(num) { + return encode(num >> 18 & 0x3F) + encode(num >> 12 & 0x3F) + encode(num >> 6 & 0x3F) + encode(num & 0x3F); + } + + // go through the array every three bytes, we'll deal with trailing stuff later + for (i = 0, length = uint8.length - extraBytes; i < length; i += 3) { + temp = (uint8[i] << 16) + (uint8[i + 1] << 8) + (uint8[i + 2]); + output += tripletToBase64(temp); + } + + // pad the end with zeros, but make sure to not forget the extra bytes + switch (extraBytes) { + case 1: + temp = uint8[uint8.length - 1]; + output += encode(temp >> 2); + output += encode((temp << 4) & 0x3F); + output += "=="; + break; + case 2: + temp = (uint8[uint8.length - 2] << 8) + (uint8[uint8.length - 1]); + output += encode(temp >> 10); + output += encode((temp >> 4) & 0x3F); + output += encode((temp << 2) & 0x3F); + output += "="; + break; + default: + break; + } + + return output; +} \ No newline at end of file diff --git a/app/settings/panes/account/TwoFactorInfo.jsx b/app/settings/panes/account/TwoFactorInfo.jsx index 5417918..450dc1b 100644 --- a/app/settings/panes/account/TwoFactorInfo.jsx +++ b/app/settings/panes/account/TwoFactorInfo.jsx @@ -17,18 +17,25 @@ class TwoFactorInfo extends Component { api.get("auth/2fa/status", {}, (data) => { this.setState({ loading: false, - enrolled: data.enrolled + enrolledTOTP: data.enrolledTOTP, + enrolledWebAuthn: data.enrolledWebAuthn }); }); } manage2fa() { this.props.openModal("twoFactor", { - enrolled: this.state.enrolled + enrolled: this.state.enrolledTOTP }); } + manageWebAuthn() { + this.props.openModal("webAuthn", {}); + } + render(props, state) { + const twofactorenabled = state.enrolledTOTP || state.enrolledWebAuthn; + if (state.loading) { return
Loading, please wait... @@ -37,9 +44,19 @@ class TwoFactorInfo extends Component { return

- It's currently {state.enrolled ? "enabled" : "disabled"} on your account. + It's currently {twofactorenabled ? "enabled" : "disabled"} on your account.

- + {(state.enrolledTOTP || state.enrolledWebAuthn) &&

+ In addition to your password, you can log on to your account with a/an +

    + {state.enrolledTOTP &&
  • Authenticator app
  • } + {state.enrolledWebAuthn &&
  • Security key
  • } +
+

} + + + {state.enrolledTOTP && !state.enrolledWebAuthn && + }
; } } diff --git a/app/settings/panes/account/WebAuthnModal.jsx b/app/settings/panes/account/WebAuthnModal.jsx new file mode 100644 index 0000000..56c64aa --- /dev/null +++ b/app/settings/panes/account/WebAuthnModal.jsx @@ -0,0 +1,112 @@ +import { h, Fragment } from "preact"; +import { useState } from "preact/hooks"; + +import { uint8ToBase64 } from "base64.js"; +import api from "api.js"; +import errors from "errors.js"; + +import Modal from "ui/Modal.jsx"; +import SecurityKeyPrompt from "auth/SecurityKeyPrompt.jsx"; +import LoadingIndicator from "ui/LoadingIndicator.jsx"; + + + +function bufferDecode(value) { + return Uint8Array.from(atob(value), c => c.charCodeAt(0)); +} + +function bufferEncode(value) { + return uint8ToBase64(value) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + + + +const Stage0 = () => <>

Security keys enable you to use a physical device for two factor authentication, rather than a software generated code.

+

MyHomeworkSpace currently allows you to add a security key that supports the FIDO2 (WebAuthn) standard. This includes YubiKeys, Google Titan security keys, and others.

+; + +const Stage2 = () => <> +

Your security key has been added successfully.

+; + +export default function WebAuthnModal(props) { + const [err, setErr] = useState(""); + const [loading, setLoading] = useState(false); + const [stage, setStage] = useState(0); + const [publicKeyData, setPublicKeyData] = useState(null); + + const cancel = stage == 0 ? () => props.openModal("") : () => setStage(stage - 1); + + const cont = () => { + if (stage == 0) { + setLoading(true); + api.post("auth/2fa/beginWebAuthn", {}, (data) => { + setPublicKeyData(data.publicKey); + setLoading(false); + setStage(1); + data.publicKey.challenge = bufferDecode(data.publicKey.challenge); + data.publicKey.user.id = bufferDecode(data.publicKey.user.id); + if (data.publicKey.excludeCredentials) { + for (var i = 0; i < data.publicKey.excludeCredentials.length; i++) { + data.publicKey.excludeCredentials[i].id = bufferDecode(data.publicKey.excludeCredentials[i].id); + } + } + + navigator.credentials.create({ + publicKey: data.publicKey + }).then((credential) => { + let attestationObject = new Uint8Array(credential.response.attestationObject); + let clientDataJSON = new Uint8Array(credential.response.clientDataJSON); + let rawId = new Uint8Array(credential.rawId); + const reqData = { + id: credential.id, + rawId: bufferEncode(rawId), + type: credential.type, + response: { + attestationObject: bufferEncode(attestationObject), + clientDataJSON: bufferEncode(clientDataJSON), + }, + }; + + api.postJSON("auth/2fa/completeWebAuthn", JSON.stringify(reqData), (resp) => { + if (resp.status == "ok") { + setStage(2); + } else { + setErr(errors.getFriendlyString(resp.error)); + setStage(1); + } + }); + }).catch((err) => { + setStage(0); + setErr(err.toString()); + }); + + }); + } + + if (stage == 2) { + window.location.reload(); + } + }; + + return + + + ; +}; \ No newline at end of file diff --git a/app/ui/LoadingIndicator.jsx b/app/ui/LoadingIndicator.jsx index d2edc01..3cb51f4 100644 --- a/app/ui/LoadingIndicator.jsx +++ b/app/ui/LoadingIndicator.jsx @@ -2,6 +2,6 @@ import { h, Component } from "preact"; export default class LoadingIndicator extends Component { render(props, state) { - return ; + return ; } }; \ No newline at end of file diff --git a/app/ui/ModalManager.jsx b/app/ui/ModalManager.jsx index 7338739..cf3c757 100644 --- a/app/ui/ModalManager.jsx +++ b/app/ui/ModalManager.jsx @@ -14,6 +14,7 @@ import EnrollModal from "schools/EnrollModal.jsx"; import SchoolSettingsModal from "schools/SchoolSettingsModal.jsx"; import BackgroundModal from "settings/panes/account/BackgroundModal.jsx"; import TwoFactorModal from "settings/panes/account/TwoFactorModal.jsx"; +import WebAuthnModal from "settings/panes/account/WebAuthnModal.jsx"; import ChangeNameModal from "settings/panes/account/ChangeNameModal.jsx"; import MyApplicationDeleteModal from "settings/panes/applications/MyApplicationDeleteModal.jsx"; import MyApplicationSettingsModal from "settings/panes/applications/MyApplicationSettingsModal.jsx"; @@ -37,6 +38,7 @@ export default class ModalManager extends Component { enroll: EnrollModal, background: BackgroundModal, twoFactor: TwoFactorModal, + webAuthn: WebAuthnModal, loading: LoadingModal, changeEmail: ChangeEmailModal, changePassword: ChangePasswordModal,