From 118af615405241409d8acd326712511eabc5ead7 Mon Sep 17 00:00:00 2001 From: schmanu Date: Wed, 27 Sep 2023 15:11:45 +0200 Subject: [PATCH] feat: signer account page to enable MFA, deviceFactor recovery on login" --- package.json | 1 + .../settings/SignerAccountMFA/index.tsx | 56 ++++++ .../SignerAccountMFA/useMFASettings.ts | 32 +++ .../sidebar/SidebarNavigation/config.tsx | 4 + src/config/routes.ts | 1 + .../mpc/__tests__/useMPCWallet.test.ts | 188 ++++++++++++++++++ src/hooks/wallets/mpc/useMPCWallet.ts | 29 +-- src/pages/settings/signer-account.tsx | 37 ++++ 8 files changed, 336 insertions(+), 12 deletions(-) create mode 100644 src/components/settings/SignerAccountMFA/index.tsx create mode 100644 src/components/settings/SignerAccountMFA/useMFASettings.ts create mode 100644 src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts create mode 100644 src/pages/settings/signer-account.tsx diff --git a/package.json b/package.json index ab8f5ec26a..f5f7b6a666 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@safe-global/safe-react-components": "^2.0.6", "@sentry/react": "^7.28.1", "@sentry/tracing": "^7.28.1", + "@tkey-mpc/common-types": "^8.2.2", "@truffle/hdwallet-provider": "^2.1.4", "@web3-onboard/coinbase": "^2.2.4", "@web3-onboard/core": "^2.21.0", diff --git a/src/components/settings/SignerAccountMFA/index.tsx b/src/components/settings/SignerAccountMFA/index.tsx new file mode 100644 index 0000000000..139b0bee99 --- /dev/null +++ b/src/components/settings/SignerAccountMFA/index.tsx @@ -0,0 +1,56 @@ +import useMPC from '@/hooks/wallets/mpc/useMPC' +import { Box, Button, Typography } from '@mui/material' +import { COREKIT_STATUS } from '@web3auth/mpc-core-kit' +import { getPubKeyPoint } from '@tkey-mpc/common-types' +import useMFASettings from './useMFASettings' +import { BN } from 'bn.js' +import { useState } from 'react' + +const SignerAccountMFA = () => { + const mpcCoreKit = useMPC() + const mfaSettings = useMFASettings() + + const [enablingMFA, setEnablingMFA] = useState(false) + + const enableMFA = async () => { + setEnablingMFA(true) + if (!mpcCoreKit) { + return + } + try { + // First enable MFA in mpcCoreKit + const recoveryFactor = await mpcCoreKit.enableMFA({}) + + // Then remove the recovery factor the mpcCoreKit creates + const recoverKey = new BN(recoveryFactor, 'hex') + const recoverPubKey = getPubKeyPoint(recoverKey) + await mpcCoreKit.deleteFactor(recoverPubKey, recoverKey) + } catch (error) { + console.error(error) + } finally { + setEnablingMFA(false) + } + } + + if (mpcCoreKit?.status !== COREKIT_STATUS.LOGGED_IN) { + return ( + + You are currently not logged in through a social account + + ) + } + + return ( + + {mfaSettings?.mfaEnabled ? ( + MFA is enabled! + ) : ( + + )} + + ) +} + +export default SignerAccountMFA diff --git a/src/components/settings/SignerAccountMFA/useMFASettings.ts b/src/components/settings/SignerAccountMFA/useMFASettings.ts new file mode 100644 index 0000000000..29726087c0 --- /dev/null +++ b/src/components/settings/SignerAccountMFA/useMFASettings.ts @@ -0,0 +1,32 @@ +import useMPC from '@/hooks/wallets/mpc/useMPC' +import { COREKIT_STATUS } from '@web3auth/mpc-core-kit' + +export type MFASettings = { + mfaEnabled: boolean +} | null + +const useMFASettings = () => { + const mpcCoreKit = useMPC() + + if (mpcCoreKit?.status !== COREKIT_STATUS.LOGGED_IN) { + return null + } + + const { shareDescriptions } = mpcCoreKit?.getKeyDetails() + + const hashedShareModuleFactor = Object.entries(shareDescriptions).find(([key, value]) => + value[0]?.includes('hashedShare'), + ) + + if (hashedShareModuleFactor) { + return { + mfaEnabled: false, + } + } + + return { + mfaEnabled: true, + } +} + +export default useMFASettings diff --git a/src/components/sidebar/SidebarNavigation/config.tsx b/src/components/sidebar/SidebarNavigation/config.tsx index dc7df0699c..05eb71effb 100644 --- a/src/components/sidebar/SidebarNavigation/config.tsx +++ b/src/components/sidebar/SidebarNavigation/config.tsx @@ -104,6 +104,10 @@ export const settingsNavItems = [ label: 'Environment variables', href: AppRoutes.settings.environmentVariables, }, + { + label: 'Signer Account', + href: AppRoutes.settings.signerAccount, + }, ] export const generalSettingsNavItems = [ diff --git a/src/config/routes.ts b/src/config/routes.ts index 6bea1cd90d..ad24a9cf41 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -27,6 +27,7 @@ export const AppRoutes = { }, settings: { spendingLimits: '/settings/spending-limits', + signerAccount: '/settings/signer-account', setup: '/settings/setup', modules: '/settings/modules', index: '/settings', diff --git a/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts b/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts new file mode 100644 index 0000000000..ebb0e7fc7a --- /dev/null +++ b/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts @@ -0,0 +1,188 @@ +import { act, renderHook, waitFor } from '@/tests/test-utils' +import { MPCWalletState, useMPCWallet } from '../useMPCWallet' +import * as useOnboard from '@/hooks/wallets/useOnboard' +import { type OnboardAPI } from '@web3-onboard/core' +import { COREKIT_STATUS, type UserInfo, type OauthLoginParams, type Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit' +import * as mpcCoreKit from '@web3auth/mpc-core-kit' +import { setMPCCoreKitInstance } from '../useMPC' +import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/module' +import { ethers } from 'ethers' +import BN from 'bn.js' + +const MOCK_LOGIN_TIME = 1000 +class MockMPCCoreKit { + status: COREKIT_STATUS = COREKIT_STATUS.INITIALIZED + state: { + userInfo: UserInfo | undefined + } = { + userInfo: undefined, + } + + private stateAfterLogin: COREKIT_STATUS + private userInfoAfterLogin: UserInfo | undefined + private expectedFactorKey: BN + constructor(stateAfterLogin: COREKIT_STATUS, userInfoAfterLogin: UserInfo, expectedFactorKey: BN = new BN(-1)) { + this.stateAfterLogin = stateAfterLogin + this.userInfoAfterLogin = userInfoAfterLogin + this.expectedFactorKey = expectedFactorKey + } + + loginWithOauth(params: OauthLoginParams): Promise { + return new Promise((resolve) => { + // Resolve after 1 sec + setTimeout(() => { + this.status = this.stateAfterLogin + this.state.userInfo = this.userInfoAfterLogin + resolve() + }, 1000) + }) + } + + inputFactorKey(factorKey: BN) { + if (factorKey.eq(this.expectedFactorKey)) { + this.status = COREKIT_STATUS.LOGGED_IN + return Promise.resolve() + } else { + Promise.reject() + } + } +} + +describe('useMPCWallet', () => { + beforeAll(() => { + jest.useFakeTimers() + }) + beforeEach(() => { + jest.resetAllMocks() + }) + afterAll(() => { + jest.useRealTimers() + }) + it('should have state NOT_INITIALIZED initially', () => { + const { result } = renderHook(() => useMPCWallet()) + expect(result.current.walletState).toBe(MPCWalletState.NOT_INITIALIZED) + expect(result.current.userInfo.email).toBeUndefined() + }) + + describe('triggerLogin', () => { + it('should throw if Onboard is not initialized', () => { + const { result } = renderHook(() => useMPCWallet()) + expect(result.current.triggerLogin()).rejects.toEqual(new Error('Onboard is not initialized')) + expect(result.current.walletState).toBe(MPCWalletState.NOT_INITIALIZED) + }) + + it('should throw if MPC Core Kit is not initialized', () => { + jest.spyOn(useOnboard, 'default').mockReturnValue({} as unknown as OnboardAPI) + const { result } = renderHook(() => useMPCWallet()) + + expect(result.current.triggerLogin()).rejects.toEqual(new Error('MPC Core Kit is not initialized')) + expect(result.current.walletState).toBe(MPCWalletState.NOT_INITIALIZED) + }) + + it('should handle successful log in for SFA account', async () => { + jest.spyOn(useOnboard, 'default').mockReturnValue({} as unknown as OnboardAPI) + const connectWalletSpy = jest.fn().mockImplementation(() => Promise.resolve()) + jest.spyOn(useOnboard, 'connectWallet').mockImplementation(connectWalletSpy) + setMPCCoreKitInstance( + new MockMPCCoreKit(COREKIT_STATUS.LOGGED_IN, { + email: 'test@test.com', + name: 'Test', + } as unknown as UserInfo) as unknown as Web3AuthMPCCoreKit, + ) + const { result } = renderHook(() => useMPCWallet()) + + act(() => { + result.current.triggerLogin() + }) + + expect(result.current.walletState === MPCWalletState.AUTHENTICATING) + expect(connectWalletSpy).not.toBeCalled() + + jest.advanceTimersByTime(MOCK_LOGIN_TIME) + + await waitFor(() => { + expect(result.current.walletState === MPCWalletState.READY) + expect(connectWalletSpy).toBeCalledWith(expect.anything(), { + autoSelect: { + label: ONBOARD_MPC_MODULE_LABEL, + disableModals: true, + }, + }) + }) + }) + + it('should handle successful log in for MFA account with device share', async () => { + const mockDeviceFactor = ethers.Wallet.createRandom().privateKey.slice(2) + jest.spyOn(useOnboard, 'default').mockReturnValue({} as unknown as OnboardAPI) + const connectWalletSpy = jest.fn().mockImplementation(() => Promise.resolve()) + jest.spyOn(useOnboard, 'connectWallet').mockImplementation(connectWalletSpy) + setMPCCoreKitInstance( + new MockMPCCoreKit( + COREKIT_STATUS.REQUIRED_SHARE, + { + email: 'test@test.com', + name: 'Test', + } as unknown as UserInfo, + new BN(mockDeviceFactor, 'hex'), + ) as unknown as Web3AuthMPCCoreKit, + ) + + jest.spyOn(mpcCoreKit, 'getWebBrowserFactor').mockReturnValue(Promise.resolve(mockDeviceFactor)) + + const { result } = renderHook(() => useMPCWallet()) + + act(() => { + result.current.triggerLogin() + }) + + expect(result.current.walletState === MPCWalletState.AUTHENTICATING) + expect(connectWalletSpy).not.toBeCalled() + + jest.advanceTimersByTime(MOCK_LOGIN_TIME) + + await waitFor(() => { + expect(result.current.walletState === MPCWalletState.READY) + expect(connectWalletSpy).toBeCalledWith(expect.anything(), { + autoSelect: { + label: ONBOARD_MPC_MODULE_LABEL, + disableModals: true, + }, + }) + }) + }) + }) + + describe('resetAccount', () => { + it('should throw if mpcCoreKit is not initialized', () => { + const { result } = renderHook(() => useMPCWallet()) + expect(result.current.resetAccount()).rejects.toEqual( + new Error('MPC Core Kit is not initialized or the user is not logged in'), + ) + }) + it('should reset an account by overwriting the metadata', async () => { + const mockSetMetadata = jest.fn() + const mockMPCCore = { + metadataKey: ethers.Wallet.createRandom().privateKey.slice(2), + state: { + userInfo: undefined, + }, + tKey: { + storageLayer: { + setMetadata: mockSetMetadata, + }, + }, + } + + setMPCCoreKitInstance(mockMPCCore as unknown as Web3AuthMPCCoreKit) + + const { result } = renderHook(() => useMPCWallet()) + + await result.current.resetAccount() + + expect(mockSetMetadata).toHaveBeenCalledWith({ + privKey: new BN(mockMPCCore.metadataKey, 'hex'), + input: { message: 'KEY_NOT_FOUND' }, + }) + }) + }) +}) diff --git a/src/hooks/wallets/mpc/useMPCWallet.ts b/src/hooks/wallets/mpc/useMPCWallet.ts index f0d54044c5..42fd5e097f 100644 --- a/src/hooks/wallets/mpc/useMPCWallet.ts +++ b/src/hooks/wallets/mpc/useMPCWallet.ts @@ -2,18 +2,13 @@ import { useState } from 'react' import useMPC from './useMPC' import BN from 'bn.js' import { GOOGLE_CLIENT_ID, WEB3AUTH_VERIFIER_ID } from '@/config/constants' -import { COREKIT_STATUS } from '@web3auth/mpc-core-kit' +import { COREKIT_STATUS, getWebBrowserFactor } from '@web3auth/mpc-core-kit' import useOnboard, { connectWallet } from '../useOnboard' import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/module' export enum MPCWalletState { NOT_INITIALIZED, AUTHENTICATING, - AUTHENTICATED, - CREATING_SECOND_FACTOR, - RECOVERING_ACCOUNT_PASSWORD, - CREATED_SECOND_FACTOR, - FINALIZING_ACCOUNT, READY, } @@ -38,7 +33,7 @@ export const useMPCWallet = (): MPCWalletHook => { // Resetting your account means clearing all the metadata associated with it from the metadata server // The key details will be deleted from our server and you will not be able to recover your account if (!mpcCoreKit || !mpcCoreKit.metadataKey) { - throw new Error('coreKitInstance is not set or the user is not logged in') + throw new Error('MPC Core Kit is not initialized or the user is not logged in') } // In web3auth an account is reset by overwriting the metadata with KEY_NOT_FOUND @@ -50,13 +45,11 @@ export const useMPCWallet = (): MPCWalletHook => { const triggerLogin = async () => { if (!onboard) { - console.error('Onboard not initialized') - return + throw Error('Onboard is not initialized') } if (!mpcCoreKit) { - console.error('tKey not initialized yet') - return + throw Error('MPC Core Kit is not initialized') } try { setWalletState(MPCWalletState.AUTHENTICATING) @@ -68,6 +61,18 @@ export const useMPCWallet = (): MPCWalletHook => { }, }) + if (mpcCoreKit.status === COREKIT_STATUS.REQUIRED_SHARE) { + // Check if we have a device share stored + const deviceFactor = await getWebBrowserFactor(mpcCoreKit) + if (deviceFactor) { + // Recover from device factor + const deviceFactorKey = new BN(deviceFactor, 'hex') + await mpcCoreKit.inputFactorKey(deviceFactorKey) + } + } + + // TODO: IF still required share, trigger another recovery option (i.e. Security Questions) or throw error as unrecoverable + if (mpcCoreKit.status === COREKIT_STATUS.LOGGED_IN) { connectWallet(onboard, { autoSelect: { @@ -77,7 +82,7 @@ export const useMPCWallet = (): MPCWalletHook => { }).catch((reason) => console.error('Error connecting to MPC module:', reason)) } - setWalletState(MPCWalletState.AUTHENTICATED) + setWalletState(MPCWalletState.READY) } catch (error) { setWalletState(MPCWalletState.NOT_INITIALIZED) console.error(error) diff --git a/src/pages/settings/signer-account.tsx b/src/pages/settings/signer-account.tsx new file mode 100644 index 0000000000..dad68286bd --- /dev/null +++ b/src/pages/settings/signer-account.tsx @@ -0,0 +1,37 @@ +import { Grid, Paper, Typography } from '@mui/material' + +import type { NextPage } from 'next' +import Head from 'next/head' + +import SettingsHeader from '@/components/settings/SettingsHeader' +import SignerAccountMFA from '@/components/settings/SignerAccountMFA' + +const SignerAccountPage: NextPage = () => { + return ( + <> + + {'Safe{Wallet} – Settings – Signer Account'} + + + + +
+ + + + + Multi factor Authentication + + + + + + + + +
+ + ) +} + +export default SignerAccountPage