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/common/ConnectWallet/MPCWallet.tsx b/src/components/common/ConnectWallet/MPCWallet.tsx index 3799144921..86b5e05ca6 100644 --- a/src/components/common/ConnectWallet/MPCWallet.tsx +++ b/src/components/common/ConnectWallet/MPCWallet.tsx @@ -1,9 +1,12 @@ +import { MPCWalletState } from '@/hooks/wallets/mpc/useMPCWallet' import { Box, Button, CircularProgress } from '@mui/material' import { useContext } from 'react' import { MpcWalletContext } from './MPCWalletProvider' +import { PasswordRecovery } from './PasswordRecovery' export const MPCWallet = () => { - const { loginPending, triggerLogin, resetAccount, userInfo } = useContext(MpcWalletContext) + const { loginPending, triggerLogin, resetAccount, userInfo, walletState, recoverFactorWithPassword } = + useContext(MpcWalletContext) return ( <> @@ -28,6 +31,10 @@ export const MPCWallet = () => { )} )} + + {walletState === MPCWalletState.MANUAL_RECOVERY && ( + + )} ) } diff --git a/src/components/common/ConnectWallet/MPCWalletProvider.tsx b/src/components/common/ConnectWallet/MPCWalletProvider.tsx index 30e2be7117..30360bdcb2 100644 --- a/src/components/common/ConnectWallet/MPCWalletProvider.tsx +++ b/src/components/common/ConnectWallet/MPCWalletProvider.tsx @@ -6,7 +6,7 @@ type MPCWalletContext = { triggerLogin: () => Promise resetAccount: () => Promise upsertPasswordBackup: (password: string) => Promise - recoverFactorWithPassword: (password: string) => Promise + recoverFactorWithPassword: (password: string, storeDeviceFactor: boolean) => Promise walletState: MPCWalletState userInfo: { email: string | undefined diff --git a/src/components/common/ConnectWallet/PasswordRecovery.tsx b/src/components/common/ConnectWallet/PasswordRecovery.tsx new file mode 100644 index 0000000000..17c8fb6060 --- /dev/null +++ b/src/components/common/ConnectWallet/PasswordRecovery.tsx @@ -0,0 +1,64 @@ +import { VisibilityOff, Visibility } from '@mui/icons-material' +import { + DialogContent, + Typography, + TextField, + IconButton, + FormControlLabel, + Checkbox, + Button, + Box, +} from '@mui/material' +import { useState } from 'react' +import ModalDialog from '../ModalDialog' + +export const PasswordRecovery = ({ + recoverFactorWithPassword, +}: { + recoverFactorWithPassword: (password: string, storeDeviceFactor: boolean) => Promise +}) => { + const [showPassword, setShowPassword] = useState(false) + const [recoveryPassword, setRecoveryPassword] = useState('') + const [storeDeviceFactor, setStoreDeviceFactor] = useState(false) + return ( + + + + + This browser is not registered with your Account yet. Please enter your recovery password to restore access + to this Account. + + + { + setRecoveryPassword(event.target.value) + }} + InputProps={{ + endAdornment: ( + setShowPassword((prev) => !prev)} + edge="end" + > + {showPassword ? : } + + ), + }} + /> + setStoreDeviceFactor((prev) => !prev)} />} + label="Do not ask again on this device" + /> + + + + + + + ) +} diff --git a/src/components/new-safe/create/steps/ConnectWalletStep/index.tsx b/src/components/new-safe/create/steps/ConnectWalletStep/index.tsx index 8081ca8e8a..c242060b5d 100644 --- a/src/components/new-safe/create/steps/ConnectWalletStep/index.tsx +++ b/src/components/new-safe/create/steps/ConnectWalletStep/index.tsx @@ -1,8 +1,6 @@ import { useEffect, useState } from 'react' -import { Box, Button, Divider, Grid, Typography } from '@mui/material' +import { Box, Button, Divider, Typography } from '@mui/material' import useWallet from '@/hooks/wallets/useWallet' -import { useCurrentChain } from '@/hooks/useChains' -import { isPairingSupported } from '@/services/pairing/utils' import type { NewSafeFormData } from '@/components/new-safe/create' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' @@ -10,16 +8,12 @@ import useSyncSafeCreationStep from '@/components/new-safe/create/useSyncSafeCre import layoutCss from '@/components/new-safe/create/styles.module.css' import useConnectWallet from '@/components/common/ConnectWallet/useConnectWallet' import KeyholeIcon from '@/components/common/icons/KeyholeIcon' -import PairingDescription from '@/components/common/PairingDetails/PairingDescription' -import PairingQRCode from '@/components/common/PairingDetails/PairingQRCode' import { usePendingSafe } from '../StatusStep/usePendingSafe' import { MPCWallet } from '@/components/common/ConnectWallet/MPCWallet' const ConnectWalletStep = ({ onSubmit, setStep }: StepRenderProps) => { const [pendingSafe] = usePendingSafe() const wallet = useWallet() - const chain = useCurrentChain() - const isSupported = isPairingSupported(chain?.disabledWallets) const handleConnect = useConnectWallet() const [, setSubmitted] = useState(false) useSyncSafeCreationStep(setStep) @@ -37,33 +31,21 @@ const ConnectWalletStep = ({ onSubmit, setStep }: StepRenderProps - - - - - + + + + - + - - or - + + or + - - - - {isSupported && ( - - - - Connect to {'Safe{Wallet}'} mobile - - - - )} - + + ) diff --git a/src/components/settings/SignerAccountMFA/PasswordForm.tsx b/src/components/settings/SignerAccountMFA/PasswordForm.tsx new file mode 100644 index 0000000000..0f4d905974 --- /dev/null +++ b/src/components/settings/SignerAccountMFA/PasswordForm.tsx @@ -0,0 +1,124 @@ +import { DeviceShareRecovery } from '@/hooks/wallets/mpc/recovery/DeviceShareRecovery' +import { SecurityQuestionRecovery } from '@/hooks/wallets/mpc/recovery/SecurityQuestionRecovery' +import { Typography, TextField, FormControlLabel, Checkbox, Button, Box } from '@mui/material' +import { type Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit' +import { useState, useMemo } from 'react' +import { Controller, useForm } from 'react-hook-form' +import { enableMFA } from './helper' + +enum PasswordFieldNames { + oldPassword = 'oldPassword', + newPassword = 'newPassword', + confirmPassword = 'confirmPassword', + storeDeviceShare = 'storeDeviceShare', +} + +type PasswordFormData = { + [PasswordFieldNames.oldPassword]: string | undefined + [PasswordFieldNames.newPassword]: string + [PasswordFieldNames.confirmPassword]: string + [PasswordFieldNames.storeDeviceShare]: boolean +} + +export const PasswordForm = ({ mpcCoreKit }: { mpcCoreKit: Web3AuthMPCCoreKit }) => { + const formMethods = useForm({ + mode: 'all', + defaultValues: async () => { + const isDeviceShareStored = await new DeviceShareRecovery(mpcCoreKit).isEnabled() + return { + [PasswordFieldNames.confirmPassword]: '', + [PasswordFieldNames.oldPassword]: undefined, + [PasswordFieldNames.newPassword]: '', + [PasswordFieldNames.storeDeviceShare]: isDeviceShareStored, + } + }, + }) + + const { register, formState, getValues, control, handleSubmit } = formMethods + + const [enablingMFA, setEnablingMFA] = useState(false) + + const isPasswordSet = useMemo(() => { + const securityQuestions = new SecurityQuestionRecovery(mpcCoreKit) + return securityQuestions.isEnabled() + }, [mpcCoreKit]) + + const onSubmit = async (data: PasswordFormData) => { + setEnablingMFA(true) + try { + await enableMFA(mpcCoreKit, data) + } finally { + setEnablingMFA(false) + } + } + + return ( +
+ + {isPasswordSet ? ( + You already have a recovery password setup. + ) : ( + You have no password setup. We suggest adding one to secure your Account. + )} + {isPasswordSet && ( + + )} + + { + const currentNewPW = getValues(PasswordFieldNames.newPassword) + if (value !== currentNewPW) { + return 'Passwords do not match' + } + }, + })} + /> + + ( + } + label="Do not ask for second factor on this device" + /> + )} + /> + + + +
+ ) +} diff --git a/src/components/settings/SignerAccountMFA/helper.ts b/src/components/settings/SignerAccountMFA/helper.ts new file mode 100644 index 0000000000..9984df01cf --- /dev/null +++ b/src/components/settings/SignerAccountMFA/helper.ts @@ -0,0 +1,68 @@ +import { DeviceShareRecovery } from '@/hooks/wallets/mpc/recovery/DeviceShareRecovery' +import { SecurityQuestionRecovery } from '@/hooks/wallets/mpc/recovery/SecurityQuestionRecovery' +import { logError } from '@/services/exceptions' +import ErrorCodes from '@/services/exceptions/ErrorCodes' +import { asError } from '@/services/exceptions/utils' +import { getPubKeyPoint } from '@tkey-mpc/common-types' +import { type Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit' +import BN from 'bn.js' + +export const isMFAEnabled = (mpcCoreKit: Web3AuthMPCCoreKit) => { + if (!mpcCoreKit) { + return false + } + const { shareDescriptions } = mpcCoreKit.getKeyDetails() + return !Object.values(shareDescriptions).some((value) => value[0]?.includes('hashedShare')) +} + +export const enableMFA = async ( + mpcCoreKit: Web3AuthMPCCoreKit, + { + newPassword, + oldPassword, + storeDeviceShare, + }: { + newPassword: string + oldPassword: string | undefined + storeDeviceShare: boolean + }, +) => { + if (!mpcCoreKit) { + return + } + const securityQuestions = new SecurityQuestionRecovery(mpcCoreKit) + const deviceShareRecovery = new DeviceShareRecovery(mpcCoreKit) + try { + // 1. setup device factor with password recovery + await securityQuestions.upsertPassword(newPassword, oldPassword) + const securityQuestionFactor = await securityQuestions.recoverWithPassword(newPassword) + if (!securityQuestionFactor) { + throw Error('Could not recover using the new password recovery') + } + + if (!isMFAEnabled(mpcCoreKit)) { + // 2. enable MFA in mpcCoreKit + const recoveryFactor = await mpcCoreKit.enableMFA({}) + + // 3. remove the recovery factor the mpcCoreKit creates + const recoverKey = new BN(recoveryFactor, 'hex') + const recoverPubKey = getPubKeyPoint(recoverKey) + await mpcCoreKit.deleteFactor(recoverPubKey, recoverKey) + } + + const hasDeviceShare = await deviceShareRecovery.isEnabled() + + if (!hasDeviceShare && storeDeviceShare) { + await deviceShareRecovery.createAndStoreDeviceFactor() + } + + if (hasDeviceShare && !storeDeviceShare) { + // Switch to password recovery factor such that we can delete the device factor + await mpcCoreKit.inputFactorKey(new BN(securityQuestionFactor, 'hex')) + await deviceShareRecovery.removeDeviceFactor() + } + } catch (e) { + const error = asError(e) + logError(ErrorCodes._304, error.message) + } +} diff --git a/src/components/settings/SignerAccountMFA/index.tsx b/src/components/settings/SignerAccountMFA/index.tsx new file mode 100644 index 0000000000..b3918f7c65 --- /dev/null +++ b/src/components/settings/SignerAccountMFA/index.tsx @@ -0,0 +1,21 @@ +import useMPC from '@/hooks/wallets/mpc/useMPC' +import { Box, Typography } from '@mui/material' +import { COREKIT_STATUS } from '@web3auth/mpc-core-kit' + +import { PasswordForm } from './PasswordForm' + +const SignerAccountMFA = () => { + const mpcCoreKit = useMPC() + + if (mpcCoreKit?.status !== COREKIT_STATUS.LOGGED_IN) { + return ( + + You are currently not logged in with a social account + + ) + } + + return +} + +export default SignerAccountMFA diff --git a/src/components/sidebar/SidebarNavigation/config.tsx b/src/components/sidebar/SidebarNavigation/config.tsx index dc7df0699c..b3c940981c 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__/useMPC.test.ts b/src/hooks/wallets/mpc/__tests__/useMPC.test.ts index 3f67c0a1e7..edf9dfe9c3 100644 --- a/src/hooks/wallets/mpc/__tests__/useMPC.test.ts +++ b/src/hooks/wallets/mpc/__tests__/useMPC.test.ts @@ -1,6 +1,6 @@ import * as useOnboard from '@/hooks/wallets/useOnboard' import { renderHook, waitFor } from '@/tests/test-utils' -import { getMPCCoreKitInstance, setMPCCoreKitInstance, useInitMPC } from '../useMPC' +import { _getMPCCoreKitInstance, setMPCCoreKitInstance, useInitMPC } from '../useMPC' import * as useChains from '@/hooks/useChains' import { type ChainInfo, RPC_AUTHENTICATION } from '@safe-global/safe-gateway-typescript-sdk' import { hexZeroPad } from 'ethers/lib/utils' @@ -104,7 +104,7 @@ describe('useInitMPC', () => { renderHook(() => useInitMPC()) await waitFor(() => { - expect(getMPCCoreKitInstance()).toBeDefined() + expect(_getMPCCoreKitInstance()).toBeDefined() expect(connectWalletSpy).not.toBeCalled() }) }) @@ -151,7 +151,7 @@ describe('useInitMPC', () => { await waitFor(() => { expect(connectWalletSpy).toBeCalled() - expect(getMPCCoreKitInstance()).toBeDefined() + expect(_getMPCCoreKitInstance()).toBeDefined() }) }) @@ -215,7 +215,7 @@ describe('useInitMPC', () => { await waitFor(() => { expect(mockChainChangedListener).toHaveBeenCalledWith('0x5') - expect(getMPCCoreKitInstance()).toBeDefined() + expect(_getMPCCoreKitInstance()).toBeDefined() expect(connectWalletSpy).not.toBeCalled() }) }) 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..c6af1f84dd --- /dev/null +++ b/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts @@ -0,0 +1,317 @@ +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, + type TssSecurityQuestion, +} 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' + +/** time until mock login resolves */ +const MOCK_LOGIN_TIME = 1000 + +/** + * Helper class for mocking MPC Core Kit login flow + */ +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 + /** + * + * @param stateAfterLogin State after loginWithOauth resolves + * @param userInfoAfterLogin User info to set in the state after loginWithOauth resolves + * @param expectedFactorKey For MFA login flow the expected factor key. If inputFactorKey gets called with the expected factor key the state switches to logged in + */ + 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() + }, MOCK_LOGIN_TIME) + }) + } + + 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() + setMPCCoreKitInstance(undefined) + }) + 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() + }) + + // While the login resolves we are in Authenticating state + expect(result.current.walletState === MPCWalletState.AUTHENTICATING) + expect(connectWalletSpy).not.toBeCalled() + + // Resolve mock login + act(() => { + jest.advanceTimersByTime(MOCK_LOGIN_TIME) + }) + + // We should be logged in and onboard should get connected + 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() + }) + + // While the login resolves we are in Authenticating state + expect(result.current.walletState === MPCWalletState.AUTHENTICATING) + expect(connectWalletSpy).not.toBeCalled() + + // Resolve mock login + act(() => { + jest.advanceTimersByTime(MOCK_LOGIN_TIME) + }) + + // We should be logged in and onboard should get connected + await waitFor(() => { + expect(result.current.walletState === MPCWalletState.READY) + expect(connectWalletSpy).toBeCalledWith(expect.anything(), { + autoSelect: { + label: ONBOARD_MPC_MODULE_LABEL, + disableModals: true, + }, + }) + }) + }) + + it('should require manual share for MFA account without device share', 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.REQUIRED_SHARE, { + email: 'test@test.com', + name: 'Test', + } as unknown as UserInfo) as unknown as Web3AuthMPCCoreKit, + ) + + // TODO: remove unnecessary cast if mpc core sdk gets updated + jest.spyOn(mpcCoreKit, 'getWebBrowserFactor').mockReturnValue(Promise.resolve(undefined as unknown as string)) + jest.spyOn(mpcCoreKit, 'TssSecurityQuestion').mockReturnValue({ + getQuestion: () => 'SOME RANDOM QUESTION', + } as unknown as TssSecurityQuestion) + + const { result } = renderHook(() => useMPCWallet()) + + act(() => { + result.current.triggerLogin() + }) + + // While the login resolves we are in Authenticating state + expect(result.current.walletState === MPCWalletState.AUTHENTICATING) + expect(connectWalletSpy).not.toBeCalled() + + // Resolve mock login + act(() => { + jest.advanceTimersByTime(MOCK_LOGIN_TIME) + }) + + // A missing second factor should result in manual recovery state + await waitFor(() => { + expect(result.current.walletState === MPCWalletState.MANUAL_RECOVERY) + expect(connectWalletSpy).not.toBeCalled() + }) + }) + }) + + 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' }, + }) + }) + }) + + describe('recoverFactorWithPassword', () => { + it('should throw if mpcCoreKit is not initialized', () => { + const { result } = renderHook(() => useMPCWallet()) + expect(result.current.recoverFactorWithPassword('test', false)).rejects.toEqual( + new Error('MPC Core Kit is not initialized'), + ) + }) + + it('should not recover if wrong password is entered', () => { + setMPCCoreKitInstance({ + state: { + userInfo: undefined, + }, + } as unknown as Web3AuthMPCCoreKit) + const { result } = renderHook(() => useMPCWallet()) + jest.spyOn(mpcCoreKit, 'TssSecurityQuestion').mockReturnValue({ + getQuestion: () => 'SOME RANDOM QUESTION', + recoverFactor: () => { + throw new Error('Invalid answer') + }, + } as unknown as TssSecurityQuestion) + + expect(result.current.recoverFactorWithPassword('test', false)).rejects.toEqual(new Error('Invalid answer')) + }) + + it('should input recovered factor if correct password is entered', async () => { + const mockSecurityQuestionFactor = ethers.Wallet.createRandom().privateKey.slice(2) + const connectWalletSpy = jest.fn().mockImplementation(() => Promise.resolve()) + jest.spyOn(useOnboard, 'default').mockReturnValue({} as unknown as OnboardAPI) + 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(mockSecurityQuestionFactor, 'hex'), + ) as unknown as Web3AuthMPCCoreKit, + ) + + const { result } = renderHook(() => useMPCWallet()) + jest.spyOn(mpcCoreKit, 'TssSecurityQuestion').mockReturnValue({ + getQuestion: () => 'SOME RANDOM QUESTION', + recoverFactor: () => Promise.resolve(mockSecurityQuestionFactor), + } as unknown as TssSecurityQuestion) + + act(() => result.current.recoverFactorWithPassword('test', false)) + + await waitFor(() => { + expect(result.current.walletState === MPCWalletState.READY) + expect(connectWalletSpy).toBeCalledWith(expect.anything(), { + autoSelect: { + label: ONBOARD_MPC_MODULE_LABEL, + disableModals: true, + }, + }) + }) + }) + }) +}) diff --git a/src/hooks/wallets/mpc/recovery/DeviceShareRecovery.ts b/src/hooks/wallets/mpc/recovery/DeviceShareRecovery.ts new file mode 100644 index 0000000000..8a080ec626 --- /dev/null +++ b/src/hooks/wallets/mpc/recovery/DeviceShareRecovery.ts @@ -0,0 +1,44 @@ +import { + BrowserStorage, + getWebBrowserFactor, + storeWebBrowserFactor, + TssShareType, + type Web3AuthMPCCoreKit, +} from '@web3auth/mpc-core-kit' +import BN from 'bn.js' +import { getPubKeyPoint } from '@tkey-mpc/common-types' + +export class DeviceShareRecovery { + private mpcCoreKit: Web3AuthMPCCoreKit + + constructor(mpcCoreKit: Web3AuthMPCCoreKit) { + this.mpcCoreKit = mpcCoreKit + } + + async isEnabled() { + if (!this.mpcCoreKit.tKey.metadata) { + return false + } + return !!(await getWebBrowserFactor(this.mpcCoreKit)) + } + + async createAndStoreDeviceFactor() { + const userAgent = navigator.userAgent + + const deviceFactorKey = new BN( + await this.mpcCoreKit.createFactor({ shareType: TssShareType.DEVICE, additionalMetadata: { userAgent } }), + 'hex', + ) + await storeWebBrowserFactor(deviceFactorKey, this.mpcCoreKit) + } + + async removeDeviceFactor() { + const deviceFactor = await getWebBrowserFactor(this.mpcCoreKit) + const key = new BN(deviceFactor, 'hex') + const pubKey = getPubKeyPoint(key) + const pubKeyX = pubKey.x.toString('hex', 64) + await this.mpcCoreKit.deleteFactor(pubKey) + const currentStorage = BrowserStorage.getInstance('mpc_corekit_store') + currentStorage.set(pubKeyX, undefined) + } +} diff --git a/src/hooks/wallets/mpc/recovery/SecurityQuestionRecovery.ts b/src/hooks/wallets/mpc/recovery/SecurityQuestionRecovery.ts new file mode 100644 index 0000000000..0d707cb29f --- /dev/null +++ b/src/hooks/wallets/mpc/recovery/SecurityQuestionRecovery.ts @@ -0,0 +1,48 @@ +import { TssSecurityQuestion, TssShareType, type Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit' + +export class SecurityQuestionRecovery { + /** This is only used internally in the metadata store of tKey. Not in the UI */ + private static readonly DEFAULT_SECURITY_QUESTION = 'ENTER PASSWORD' + + private mpcCoreKit: Web3AuthMPCCoreKit + private securityQuestions = new TssSecurityQuestion() + + constructor(mpcCoreKit: Web3AuthMPCCoreKit) { + this.mpcCoreKit = mpcCoreKit + } + + isEnabled(): boolean { + try { + const question = this.securityQuestions.getQuestion(this.mpcCoreKit) + return !!question + } catch (error) { + // It errors out if recovery is not setup currently + return false + } + } + + async upsertPassword(newPassword: string, oldPassword?: string) { + if (this.isEnabled()) { + if (!oldPassword) { + throw Error('To change the password you need to provide the old password') + } + await this.securityQuestions.changeSecurityQuestion({ + answer: oldPassword, + mpcCoreKit: this.mpcCoreKit, + newAnswer: newPassword, + newQuestion: SecurityQuestionRecovery.DEFAULT_SECURITY_QUESTION, + }) + } else { + await this.securityQuestions.setSecurityQuestion({ + question: SecurityQuestionRecovery.DEFAULT_SECURITY_QUESTION, + answer: newPassword, + mpcCoreKit: this.mpcCoreKit, + shareType: TssShareType.DEVICE, + }) + } + } + + async recoverWithPassword(password: string) { + return this.securityQuestions.recoverFactor(this.mpcCoreKit, password) + } +} diff --git a/src/hooks/wallets/mpc/useMPC.ts b/src/hooks/wallets/mpc/useMPC.ts index ba110255ea..c7f4607c05 100644 --- a/src/hooks/wallets/mpc/useMPC.ts +++ b/src/hooks/wallets/mpc/useMPC.ts @@ -24,7 +24,7 @@ export const useInitMPC = () => { chainNamespace: CHAIN_NAMESPACES.EIP155, rpcTarget: getRpcServiceUrl(chain.rpcUri), displayName: chain.chainName, - blockExplorer: chain.blockExplorerUriTemplate.address, + blockExplorer: new URL(chain.blockExplorerUriTemplate.address).origin, ticker: chain.nativeCurrency.symbol, tickerName: chain.nativeCurrency.name, } @@ -40,7 +40,7 @@ export const useInitMPC = () => { const web3AuthCoreKit = new Web3AuthMPCCoreKit({ web3AuthClientId: WEB3_AUTH_CLIENT_ID, // Available networks are "sapphire_devnet", "sapphire_mainnet" - web3AuthNetwork: WEB3AUTH_NETWORK.DEVNET, + web3AuthNetwork: WEB3AUTH_NETWORK.MAINNET, baseUrl: `${window.location.origin}/serviceworker`, uxMode: 'popup', enableLogging: true, @@ -79,7 +79,7 @@ export const useInitMPC = () => { }, [chain, onboard]) } -export const getMPCCoreKitInstance = getStore +export const _getMPCCoreKitInstance = getStore export const setMPCCoreKitInstance = setStore diff --git a/src/hooks/wallets/mpc/useMPCWallet.ts b/src/hooks/wallets/mpc/useMPCWallet.ts index f0d54044c5..9fc69b4d06 100644 --- a/src/hooks/wallets/mpc/useMPCWallet.ts +++ b/src/hooks/wallets/mpc/useMPCWallet.ts @@ -2,24 +2,22 @@ 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' +import { SecurityQuestionRecovery } from './recovery/SecurityQuestionRecovery' +import { DeviceShareRecovery } from './recovery/DeviceShareRecovery' export enum MPCWalletState { NOT_INITIALIZED, AUTHENTICATING, - AUTHENTICATED, - CREATING_SECOND_FACTOR, - RECOVERING_ACCOUNT_PASSWORD, - CREATED_SECOND_FACTOR, - FINALIZING_ACCOUNT, + MANUAL_RECOVERY, READY, } export type MPCWalletHook = { upsertPasswordBackup: (password: string) => Promise - recoverFactorWithPassword: (password: string) => Promise + recoverFactorWithPassword: (password: string, storeDeviceShare: boolean) => Promise walletState: MPCWalletState triggerLogin: () => Promise resetAccount: () => Promise @@ -38,7 +36,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 +48,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,26 +64,70 @@ export const useMPCWallet = (): MPCWalletHook => { }, }) - if (mpcCoreKit.status === COREKIT_STATUS.LOGGED_IN) { - connectWallet(onboard, { - autoSelect: { - label: ONBOARD_MPC_MODULE_LABEL, - disableModals: true, - }, - }).catch((reason) => console.error('Error connecting to MPC module:', reason)) + 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) + } else { + // Check password recovery + const securityQuestions = new SecurityQuestionRecovery(mpcCoreKit) + if (securityQuestions.isEnabled()) { + setWalletState(MPCWalletState.MANUAL_RECOVERY) + return + } + } } - setWalletState(MPCWalletState.AUTHENTICATED) + finalizeLogin() } catch (error) { setWalletState(MPCWalletState.NOT_INITIALIZED) console.error(error) } } + const finalizeLogin = () => { + if (!mpcCoreKit || !onboard) { + return + } + if (mpcCoreKit.status === COREKIT_STATUS.LOGGED_IN) { + connectWallet(onboard, { + autoSelect: { + label: ONBOARD_MPC_MODULE_LABEL, + disableModals: true, + }, + }).catch((reason) => console.error('Error connecting to MPC module:', reason)) + setWalletState(MPCWalletState.READY) + } + } + + const recoverFactorWithPassword = async (password: string, storeDeviceShare: boolean = false) => { + if (!mpcCoreKit) { + throw new Error('MPC Core Kit is not initialized') + } + + const securityQuestions = new SecurityQuestionRecovery(mpcCoreKit) + + if (securityQuestions.isEnabled()) { + const factorKeyString = await securityQuestions.recoverWithPassword(password) + const factorKey = new BN(factorKeyString, 'hex') + await mpcCoreKit.inputFactorKey(factorKey) + + if (storeDeviceShare) { + const deviceShareRecovery = new DeviceShareRecovery(mpcCoreKit) + await deviceShareRecovery.createAndStoreDeviceFactor() + } + + finalizeLogin() + } + } + return { triggerLogin, walletState, - recoverFactorWithPassword: () => Promise.resolve(), + recoverFactorWithPassword, resetAccount: criticalResetAccount, upsertPasswordBackup: () => Promise.resolve(), userInfo: { diff --git a/src/pages/settings/signer-account.tsx b/src/pages/settings/signer-account.tsx new file mode 100644 index 0000000000..3c0f36b91b --- /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 diff --git a/src/services/exceptions/ErrorCodes.ts b/src/services/exceptions/ErrorCodes.ts index 22fe117513..f9a6986dc7 100644 --- a/src/services/exceptions/ErrorCodes.ts +++ b/src/services/exceptions/ErrorCodes.ts @@ -16,6 +16,7 @@ enum ErrorCodes { _302 = '302: Error connecting to the wallet', _303 = '303: Error creating pairing session', + _304 = '304: Error enabling MFA', _600 = '600: Error fetching Safe info', _601 = '601: Error fetching balances', diff --git a/src/services/mpc/__tests__/module.test.ts b/src/services/mpc/__tests__/module.test.ts index 4a510b992f..3a03f09c81 100644 --- a/src/services/mpc/__tests__/module.test.ts +++ b/src/services/mpc/__tests__/module.test.ts @@ -36,7 +36,7 @@ describe('MPC Onboard module', () => { send: mockReadOnlySend, } as any) - jest.spyOn(useMPC, 'getMPCCoreKitInstance').mockImplementation(() => { + jest.spyOn(useMPC, '_getMPCCoreKitInstance').mockImplementation(() => { return { provider: {}, } as any @@ -85,7 +85,7 @@ describe('MPC Onboard module', () => { send: mockReadOnlySend, } as any) - jest.spyOn(useMPC, 'getMPCCoreKitInstance').mockImplementation(() => { + jest.spyOn(useMPC, '_getMPCCoreKitInstance').mockImplementation(() => { return { provider: { request: mockMPCProviderRequest, diff --git a/src/services/mpc/module.ts b/src/services/mpc/module.ts index 180cbddc43..1814f111d0 100644 --- a/src/services/mpc/module.ts +++ b/src/services/mpc/module.ts @@ -1,9 +1,9 @@ -import { getMPCCoreKitInstance } from '@/hooks/wallets/mpc/useMPC' +import { _getMPCCoreKitInstance } from '@/hooks/wallets/mpc/useMPC' import { getWeb3ReadOnly } from '@/hooks/wallets/web3' import { type WalletInit, ProviderRpcError } from '@web3-onboard/common' import { type EIP1193Provider } from '@web3-onboard/core' -const getMPCProvider = () => getMPCCoreKitInstance()?.provider +const getMPCProvider = () => _getMPCCoreKitInstance()?.provider const assertDefined = (mpcProvider: T | undefined) => { if (!mpcProvider) { @@ -78,7 +78,7 @@ function MpcModule(): WalletInit { return web3.removeListener(event, listener) }, disconnect: () => { - getMPCCoreKitInstance()?.logout() + _getMPCCoreKitInstance()?.logout() }, }