From e8548467d945ccb286da595a02c816abb596439d Mon Sep 17 00:00:00 2001 From: vnugent Date: Fri, 31 May 2024 15:22:19 -0400 Subject: feat: Adding fido as an mfa type --- lib/vnlib.browser/src/index.ts | 1 + lib/vnlib.browser/src/mfa/config.ts | 7 +- lib/vnlib.browser/src/mfa/fido.ts | 184 ++++++++++++++++++++++++++++++ lib/vnlib.browser/src/mfa/login.ts | 10 +- lib/vnlib.browser/src/session/internal.ts | 16 ++- lib/vnlib.browser/src/webcrypto.ts | 41 +++++-- 6 files changed, 237 insertions(+), 22 deletions(-) create mode 100644 lib/vnlib.browser/src/mfa/fido.ts (limited to 'lib/vnlib.browser/src') diff --git a/lib/vnlib.browser/src/index.ts b/lib/vnlib.browser/src/index.ts index de0f651..d450dba 100644 --- a/lib/vnlib.browser/src/index.ts +++ b/lib/vnlib.browser/src/index.ts @@ -30,6 +30,7 @@ export type { WebMessage, ServerValidationError } from './types' export * from './mfa/login' export * from './mfa/pki' export * from './mfa/config' +export * from './mfa/fido' //Social exports export * from './social' diff --git a/lib/vnlib.browser/src/mfa/config.ts b/lib/vnlib.browser/src/mfa/config.ts index d4bce35..0343ccb 100644 --- a/lib/vnlib.browser/src/mfa/config.ts +++ b/lib/vnlib.browser/src/mfa/config.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2023 Vaughn Nugent +// Copyright (c) 2024 Vaughn Nugent // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in @@ -22,6 +22,7 @@ import { type MaybeRef } from "vue"; import { useAxiosInternal } from "../axios" import type { MfaMethod } from "./login" import type { WebMessage } from '../types' +import type { AxiosRequestConfig } from "axios"; export type UserArg = object; @@ -56,9 +57,9 @@ export interface MfaApi{ * @param mfaEndpoint The server mfa endpoint relative to the base url * @returns An object containing the mfa api */ -export const useMfaConfig = (mfaEndpoint: MaybeRef): MfaApi =>{ +export const useMfaConfig = (mfaEndpoint: MaybeRef, axiosConfig?: MaybeRef): MfaApi =>{ - const axios = useAxiosInternal(null) + const axios = useAxiosInternal(axiosConfig) const getMethods = async () => { //Get the mfa methods diff --git a/lib/vnlib.browser/src/mfa/fido.ts b/lib/vnlib.browser/src/mfa/fido.ts new file mode 100644 index 0000000..5eaa166 --- /dev/null +++ b/lib/vnlib.browser/src/mfa/fido.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2024 Vaughn Nugent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import { get } from "@vueuse/core"; +import { type MaybeRef } from "vue"; +import { useAxiosInternal } from "../axios"; +import type { WebMessage } from "../types"; +import type { AxiosRequestConfig } from "axios"; +import type { + IMfaFlowContinuiation, + IMfaMessage, + IMfaTypeProcessor, + MfaSumissionHandler +} from "./login"; +import { startRegistration } from "@simplewebauthn/browser"; +import type { RegistrationResponseJSON, PublicKeyCredentialCreationOptionsJSON } from "@simplewebauthn/types"; + +export type IFidoServerOptions = PublicKeyCredentialCreationOptionsJSON + +export interface IFidoRequestOptions{ + readonly password: string; +} + +export interface IFidoDevice{ + readonly id: string; + readonly name: string; + readonly registered_at: number; +} + + +interface FidoRegistration{ + readonly id: string; + readonly publicKey?: string; + readonly publicKeyAlgorithm: number; + readonly clientDataJSON: string; + readonly authenticatorData?: string; + readonly attestationObject?: string; +} + +export interface IFidoApi { + /** + * Gets fido credential options from the server for a currently logged-in user + * @returns A promise that resolves to the server options for the FIDO API + */ + beginRegistration: (options?: Partial) => Promise; + + /** + * Creates a new credential for the currently logged-in user + * @param credential The credential to create + * @returns A promise that resolves to a web message + */ + registerCredential: (credential: RegistrationResponseJSON, commonName: string) => Promise; + + /** + * Registers the default device for the currently logged-in user + * @returns A promise that resolves to a web message status of the operation + */ + registerDefaultDevice: (commonName: string, options?: Partial) => Promise; + + /** + * Lists all devices for the currently logged-in user + * @returns A promise that resolves to a list of devices + */ + listDevices: () => Promise; + + /** + * Disables a device for the currently logged-in user. + * May require a password to be passed in the options + * @param device The device descriptor to disable + * @param options The options to pass to the server + * @returns A promise that resolves to a web message status of the operation + */ + disableDevice: (device: IFidoDevice, options?: Partial) => Promise; + + /** + * Disables all devices for the currently logged-in user. + * May require a password to be passed in the options + * @param options The options to pass to the server + * @returns A promise that resolves to a web message status of the operation + */ + disableAllDevices: (options?: Partial) => Promise; +} + +/** + * Creates a fido api for configuration and management of fido client devices + * @param endpoint The fido server endpoint + * @param axiosConfig The optional axios configuration to use + * @returns An object containing the fido api + */ +export const useFidoApi = (endpoint: MaybeRef, axiosConfig?: MaybeRef) + : IFidoApi =>{ + const ep = () => get(endpoint); + + const axios = useAxiosInternal(axiosConfig) + + const beginRegistration = async (options?: Partial) : Promise => { + const { data } = await axios.value.put>(ep(), options); + return data.getResultOrThrow(); + } + + const registerCredential = async (registration: RegistrationResponseJSON, commonName: string): Promise => { + + const response: FidoRegistration = { + id: registration.id, + publicKey: registration.response.publicKey, + publicKeyAlgorithm: registration.response.publicKeyAlgorithm!, + clientDataJSON: registration.response.clientDataJSON, + authenticatorData: registration.response.authenticatorData, + attestationObject: registration.response.attestationObject + } + + const { data } = await axios.value.post(ep(), { response, commonName }); + return data; + } + + const registerDefaultDevice = async (commonName: string, options?: Partial): Promise => { + //begin registration + const serverOptions = await beginRegistration(options); + + const reg = await startRegistration(serverOptions); + + return await registerCredential(reg, commonName); + } + + const listDevices = async (): Promise => { + const { data } = await axios.value.get>(ep()); + return data.getResultOrThrow(); + } + + const disableDevice = async (device: IFidoDevice, options?: Partial): Promise => { + const { data } = await axios.value.post(ep(), { delete: device, ...options }); + return data; + } + + const disableAllDevices = async (options?: Partial): Promise => { + const { data } = await axios.value.post(ep(), options); + return data; + } + + return { + beginRegistration, + registerCredential, + registerDefaultDevice, + listDevices, + disableDevice, + disableAllDevices + } +} + +/** + * Enables fido as a supported multi-factor authentication method + * @returns A mfa login processor for fido multi-factor + */ +export const fidoMfaProcessor = () : IMfaTypeProcessor => { + + const processMfa = (payload: IMfaMessage, onSubmit: MfaSumissionHandler) : Promise => { + + return Promise.resolve({ + ...payload, + submit: onSubmit.submit, + }) + } + + return{ + type: "fido", + processMfa + } +} \ No newline at end of file diff --git a/lib/vnlib.browser/src/mfa/login.ts b/lib/vnlib.browser/src/mfa/login.ts index a2bf120..57465ef 100644 --- a/lib/vnlib.browser/src/mfa/login.ts +++ b/lib/vnlib.browser/src/mfa/login.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2023 Vaughn Nugent +// Copyright (c) 2024 Vaughn Nugent // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in @@ -29,9 +29,7 @@ import type { Axios } from "axios"; import type { ITokenResponse } from "../session"; import type { WebMessage } from "../types"; -export enum MfaMethod { - TOTP = 'totp' -} +export type MfaMethod = 'totp' | 'fido' | 'pkotp'; export interface IMfaSubmission { /** @@ -100,7 +98,7 @@ export interface IMfaLoginManager { const getMfaProcessor = (user: IUserInternal, axios:Ref) =>{ //Store handlers by their mfa type - const handlerMap = new Map(); + const handlerMap = new Map(); //Creates a submission handler for an mfa upgrade const createSubHandler = (upgrade : string, finalize: (res: ITokenResponse) => Promise) :MfaSumissionHandler => { @@ -177,7 +175,7 @@ export const totpMfaProcessor = (): IMfaTypeProcessor => { } return { - type: MfaMethod.TOTP, + type: 'totp', processMfa } } diff --git a/lib/vnlib.browser/src/session/internal.ts b/lib/vnlib.browser/src/session/internal.ts index 4fba638..0d60a38 100644 --- a/lib/vnlib.browser/src/session/internal.ts +++ b/lib/vnlib.browser/src/session/internal.ts @@ -21,7 +21,7 @@ import { defaults, isEmpty, isNil, noop } from 'lodash-es'; import { computed, watch, type Ref } from "vue"; import { get, set, toRefs } from '@vueuse/core'; import { SignJWT } from 'jose' -import crypto, { decryptAsync, getRandomHex } from "../webcrypto"; +import { getCryptoOrThrow, decryptAsync, getRandomHex } from "../webcrypto"; import { ArrayBuffToBase64, Base64ToUint8Array } from '../binhelpers' import { debugLog } from "../util"; import type { CookieMonitor } from './cookies' @@ -63,6 +63,8 @@ const createKeyStore = (storage: Ref, keyAlg: Ref => { + const crypto = getCryptoOrThrow(); + // Store the private key const newPrivRaw = await crypto.exportKey('pkcs8', keypair.privateKey); const newPubRaw = await crypto.exportKey('spki', keypair.publicKey); @@ -83,10 +85,11 @@ const createKeyStore = (storage: Ref, keyAlg: Ref, keyAlg: Ref, keyAlg: Ref { + return !!(window.isSecureContext && window.crypto && window.crypto.subtle); +} + +export const getCryptoOrThrow = () => { + if (!isCryptoSupported()) { + throw new Error('Your browser does not support the Web Cryptography API'); + } + return window.crypto.subtle; +} /** * Signs the dataBuffer using the specified key and hmac algorithm by its name eg. 'SHA-256' @@ -29,9 +38,13 @@ const crypto = window?.crypto?.subtle || {}; * @param {String} alg The name of the hmac algorithm to use eg. 'SHA-256' * @param {String} [toBase64 = false] The output format, the array buffer data, or true for base64 string * @returns {Promise} The signature as an ArrayBuffer or a base64 string + * @throws An error if the browser does not support the Web Cryptography API */ export const hmacSignAsync = async (keyBuffer: ArrayBuffer | string, dataBuffer: ArrayBuffer | string, alg : string, toBase64 = false) : Promise => { + + const crypto = getCryptoOrThrow() + // Check key argument type const rawKeyBuffer = isString(keyBuffer) ? Base64ToUint8Array(keyBuffer as string) : keyBuffer as ArrayBuffer; @@ -47,6 +60,7 @@ export const hmacSignAsync = async (keyBuffer: ArrayBuffer | string, dataBuffer: // Encode to base64 if needed return toBase64 ? ArrayBuffToBase64(digest) : digest; } + /** * @function decryptAsync Decrypts syncrhonous or asyncrhonsous en encypted data * asynchronously. @@ -55,13 +69,17 @@ export const hmacSignAsync = async (keyBuffer: ArrayBuffer | string, dataBuffer: * @param {Object} algorithm The algorithm object to use for decryption. * @param {Boolean} toBase64 If true, the decrypted data will be returned as a base64 string. * @returns {Promise} The decrypted data. + * @throws An error if the browser does not support the Web Cryptography API */ export const decryptAsync = async ( algorithm: AlgorithmIdentifier, privKey: BufferSource | CryptoKey | JsonWebKey, data: string | ArrayBuffer, - toBase64 = false): Promise => + toBase64 = false +): Promise => { + const crypto = getCryptoOrThrow() + // Check data argument type and decode if needed const dataBuffer = isString(data) ? Base64ToUint8Array(data as string) : data as ArrayBuffer; @@ -84,14 +102,21 @@ export const decryptAsync = async ( return toBase64 ? ArrayBuffToBase64(decrypted) : decrypted } +/** + * Gets a random hex string of the specified size + * @param size The number of bytes to generate + * @returns A random hex string of the specified size + * @throws An error if the browser does not support the Web Cryptography API + */ export const getRandomHex = (size: number) : string => { - // generate a new random secret and store it + if (!isCryptoSupported()) { + throw new Error('Your browser does not support the Web Cryptography API'); + } + const randBuffer = new Uint8Array(size) - // generate random id directly on the window.crypto object + window.crypto.getRandomValues(randBuffer) - // Store the id in the session as hex + + //Convert the random buffer to a hex string return ArrayToHexString(randBuffer) } - -//default export subtle crypto -export default crypto; \ No newline at end of file -- cgit