diff --git a/src/components/common/ConnectWallet/MPCWallet.tsx b/src/components/common/ConnectWallet/MPCWallet.tsx index a176751d14..86b5e05ca6 100644 --- a/src/components/common/ConnectWallet/MPCWallet.tsx +++ b/src/components/common/ConnectWallet/MPCWallet.tsx @@ -8,8 +8,6 @@ export const MPCWallet = () => { const { loginPending, triggerLogin, resetAccount, userInfo, walletState, recoverFactorWithPassword } = useContext(MpcWalletContext) - console.log(walletState) - return ( <> {userInfo.email ? ( diff --git a/src/components/settings/SignerAccountMFA/index.tsx b/src/components/settings/SignerAccountMFA/index.tsx index fae01a8191..ea3725fea2 100644 --- a/src/components/settings/SignerAccountMFA/index.tsx +++ b/src/components/settings/SignerAccountMFA/index.tsx @@ -4,10 +4,10 @@ import { COREKIT_STATUS } from '@web3auth/mpc-core-kit' import { getPubKeyPoint } from '@tkey-mpc/common-types' import { BN } from 'bn.js' import { useEffect, useMemo, useState } from 'react' -import { useSecurityQuestions } from '@/hooks/wallets/mpc/recovery/useSecurityQuestions' +import { SecurityQuestionRecovery } from '@/hooks/wallets/mpc/recovery/SecurityQuestionRecovery' import useMFASettings from './useMFASettings' import { useForm } from 'react-hook-form' -import { useDeviceShare } from '@/hooks/wallets/mpc/recovery/useDeviceShare' +import { DeviceShareRecovery } from '@/hooks/wallets/mpc/recovery/DeviceShareRecovery' type SignerAccountFormData = { oldPassword: string | undefined @@ -19,8 +19,6 @@ type SignerAccountFormData = { const SignerAccountMFA = () => { const mpcCoreKit = useMPC() const mfaSettings = useMFASettings(mpcCoreKit) - const securityQuestions = useSecurityQuestions(mpcCoreKit) - const deviceShareModule = useDeviceShare(mpcCoreKit) const formMethods = useForm({ mode: 'all', @@ -30,19 +28,27 @@ const SignerAccountMFA = () => { const [enablingMFA, setEnablingMFA] = useState(false) - const isPasswordSet = useMemo(() => securityQuestions.isEnabled(), [securityQuestions]) - - console.log(mpcCoreKit) + const isPasswordSet = useMemo(() => { + if (!mpcCoreKit) { + return false + } + const securityQuestions = new SecurityQuestionRecovery(mpcCoreKit) + return securityQuestions.isEnabled() + }, [mpcCoreKit]) useEffect(() => { - deviceShareModule.isEnabled().then((value) => setValue('storeDeviceShare', value)) - }, [deviceShareModule, setValue]) + if (!mpcCoreKit) { + return + } + new DeviceShareRecovery(mpcCoreKit).isEnabled().then((value) => setValue('storeDeviceShare', value)) + }, [mpcCoreKit, setValue]) const enableMFA = async () => { if (!mpcCoreKit) { return } - + const securityQuestions = new SecurityQuestionRecovery(mpcCoreKit) + const deviceShareRecovery = new DeviceShareRecovery(mpcCoreKit) setEnablingMFA(true) try { const { newPassword, oldPassword, storeDeviceShare } = formMethods.getValues() @@ -63,16 +69,16 @@ const SignerAccountMFA = () => { await mpcCoreKit.deleteFactor(recoverPubKey, recoverKey) } - const hasDeviceShare = await deviceShareModule.isEnabled() + const hasDeviceShare = await deviceShareRecovery.isEnabled() if (!hasDeviceShare && storeDeviceShare) { - await deviceShareModule.createAndStoreDeviceFactor() + 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 deviceShareModule.removeDeviceFactor() + await deviceShareRecovery.removeDeviceFactor() } } catch (error) { console.error(error) @@ -90,20 +96,17 @@ const SignerAccountMFA = () => { } const onSubmit = async () => { - console.log('submitting') await enableMFA() } return (
- { - /* TODO: Memoize this*/ securityQuestions.isEnabled() ? ( - You already have a recovery password setup. - ) : ( - You have no password setup. Secure your account now! - ) - } + {isPasswordSet ? ( + You already have a recovery password setup. + ) : ( + You have no password setup. Secure your account now! + )} {isPasswordSet && ( { error={!!formState.errors['oldPassword']} helperText={formState.errors['oldPassword']?.message} {...register('oldPassword', { - required: securityQuestions.isEnabled(), + required: true, })} /> )} diff --git a/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts b/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts index 0efea9dec0..d9e16fa7e5 100644 --- a/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts +++ b/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts @@ -2,7 +2,13 @@ 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 { + 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' @@ -54,6 +60,7 @@ describe('useMPCWallet', () => { }) beforeEach(() => { jest.resetAllMocks() + setMPCCoreKitInstance(undefined) }) afterAll(() => { jest.useRealTimers() @@ -98,7 +105,9 @@ describe('useMPCWallet', () => { expect(result.current.walletState === MPCWalletState.AUTHENTICATING) expect(connectWalletSpy).not.toBeCalled() - jest.advanceTimersByTime(MOCK_LOGIN_TIME) + act(() => { + jest.advanceTimersByTime(MOCK_LOGIN_TIME) + }) await waitFor(() => { expect(result.current.walletState === MPCWalletState.READY) @@ -137,8 +146,9 @@ describe('useMPCWallet', () => { expect(result.current.walletState === MPCWalletState.AUTHENTICATING) expect(connectWalletSpy).not.toBeCalled() - - jest.advanceTimersByTime(MOCK_LOGIN_TIME) + act(() => { + jest.advanceTimersByTime(MOCK_LOGIN_TIME) + }) await waitFor(() => { expect(result.current.walletState === MPCWalletState.READY) @@ -150,6 +160,42 @@ describe('useMPCWallet', () => { }) }) }) + + 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() + }) + + expect(result.current.walletState === MPCWalletState.AUTHENTICATING) + expect(connectWalletSpy).not.toBeCalled() + + act(() => { + jest.advanceTimersByTime(MOCK_LOGIN_TIME) + }) + + await waitFor(() => { + expect(result.current.walletState === MPCWalletState.MANUAL_RECOVERY) + expect(connectWalletSpy).not.toBeCalled() + }) + }) }) describe('resetAccount', () => { @@ -185,4 +231,66 @@ describe('useMPCWallet', () => { }) }) }) + + 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.only('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..afcf78964a --- /dev/null +++ b/src/hooks/wallets/mpc/recovery/DeviceShareRecovery.ts @@ -0,0 +1,45 @@ +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') + debugger + 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..3224a07aa1 --- /dev/null +++ b/src/hooks/wallets/mpc/recovery/SecurityQuestionRecovery.ts @@ -0,0 +1,48 @@ +import { TssSecurityQuestion, TssShareType, type Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit' + +const DEFAULT_SECURITY_QUESTION = 'ENTER PASSWORD' + +export class SecurityQuestionRecovery { + 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) { + console.error(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: DEFAULT_SECURITY_QUESTION, + }) + } else { + await this.securityQuestions.setSecurityQuestion({ + question: 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/recovery/useDeviceShare.ts b/src/hooks/wallets/mpc/recovery/useDeviceShare.ts deleted file mode 100644 index 5ca317a096..0000000000 --- a/src/hooks/wallets/mpc/recovery/useDeviceShare.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 const useDeviceShare = (mpcCoreKit: Web3AuthMPCCoreKit | undefined) => { - const isEnabled = async () => { - if (!mpcCoreKit || !mpcCoreKit.tKey.metadata) { - return false - } - return !!(await getWebBrowserFactor(mpcCoreKit)) - } - - const createAndStoreDeviceFactor = async () => { - if (!mpcCoreKit) { - return - } - const userAgent = navigator.userAgent - - const deviceFactorKey = new BN( - await mpcCoreKit.createFactor({ shareType: TssShareType.DEVICE, additionalMetadata: { userAgent } }), - 'hex', - ) - await storeWebBrowserFactor(deviceFactorKey, mpcCoreKit) - } - - const removeDeviceFactor = async () => { - if (!mpcCoreKit) { - return - } - const deviceFactor = await getWebBrowserFactor(mpcCoreKit) - const key = new BN(deviceFactor, 'hex') - const pubKey = getPubKeyPoint(key) - const pubKeyX = pubKey.x.toString('hex', 64) - await mpcCoreKit.deleteFactor(pubKey) - const currentStorage = BrowserStorage.getInstance('mpc_corekit_store') - debugger - currentStorage.set(pubKeyX, undefined) - } - - return { - isEnabled, - createAndStoreDeviceFactor, - removeDeviceFactor, - } -} diff --git a/src/hooks/wallets/mpc/recovery/useSecurityQuestions.ts b/src/hooks/wallets/mpc/recovery/useSecurityQuestions.ts deleted file mode 100644 index 9e90b8a10b..0000000000 --- a/src/hooks/wallets/mpc/recovery/useSecurityQuestions.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { TssSecurityQuestion, TssShareType, type Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit' - -const DEFAULT_SECURITY_QUESTION = 'ENTER PASSWORD' - -const securityQuestions = new TssSecurityQuestion() - -export const useSecurityQuestions = (mpcCoreKit: Web3AuthMPCCoreKit | undefined) => { - const isEnabled = () => { - if (!mpcCoreKit) { - return false - } - try { - const question = securityQuestions.getQuestion(mpcCoreKit) - return !!question - } catch (error) { - console.error(error) - // It errors out if recovery is not setup currently - return false - } - } - - const upsertPassword = async (newPassword: string, oldPassword?: string) => { - if (!mpcCoreKit) { - return - } - if (isEnabled()) { - if (!oldPassword) { - throw Error('To change the password you need to provide the old password') - } - await securityQuestions.changeSecurityQuestion({ - answer: oldPassword, - mpcCoreKit, - newAnswer: newPassword, - newQuestion: DEFAULT_SECURITY_QUESTION, - }) - } else { - await securityQuestions.setSecurityQuestion({ - question: DEFAULT_SECURITY_QUESTION, - answer: newPassword, - mpcCoreKit, - shareType: TssShareType.DEVICE, - }) - } - } - - const recoverWithPassword = async (password: string) => { - if (!mpcCoreKit) { - return - } - return securityQuestions.recoverFactor(mpcCoreKit, password) - } - - return { - isEnabled, - upsertPassword, - recoverWithPassword, - } -} diff --git a/src/hooks/wallets/mpc/useMPCWallet.ts b/src/hooks/wallets/mpc/useMPCWallet.ts index e4fb5ee68c..319643c818 100644 --- a/src/hooks/wallets/mpc/useMPCWallet.ts +++ b/src/hooks/wallets/mpc/useMPCWallet.ts @@ -5,8 +5,8 @@ import { GOOGLE_CLIENT_ID, WEB3AUTH_VERIFIER_ID } from '@/config/constants' import { COREKIT_STATUS, getWebBrowserFactor } from '@web3auth/mpc-core-kit' import useOnboard, { connectWallet } from '../useOnboard' import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/module' -import { useSecurityQuestions } from './recovery/useSecurityQuestions' -import { useDeviceShare } from './recovery/useDeviceShare' +import { SecurityQuestionRecovery } from './recovery/SecurityQuestionRecovery' +import { DeviceShareRecovery } from './recovery/DeviceShareRecovery' export enum MPCWalletState { NOT_INITIALIZED, @@ -31,14 +31,12 @@ export const useMPCWallet = (): MPCWalletHook => { const [walletState, setWalletState] = useState(MPCWalletState.NOT_INITIALIZED) const mpcCoreKit = useMPC() const onboard = useOnboard() - const securityQuestions = useSecurityQuestions(mpcCoreKit) - const deviceShareModule = useDeviceShare(mpcCoreKit) const isMFAEnabled = () => { if (!mpcCoreKit) { return false } - const { shareDescriptions } = mpcCoreKit?.getKeyDetails() + const { shareDescriptions } = mpcCoreKit.getKeyDetails() return !Object.entries(shareDescriptions).some(([key, value]) => value[0]?.includes('hashedShare')) } @@ -85,6 +83,7 @@ export const useMPCWallet = (): MPCWalletHook => { await mpcCoreKit.inputFactorKey(deviceFactorKey) } else { // Check password recovery + const securityQuestions = new SecurityQuestionRecovery(mpcCoreKit) if (securityQuestions.isEnabled()) { setWalletState(MPCWalletState.MANUAL_RECOVERY) return @@ -115,16 +114,20 @@ export const useMPCWallet = (): MPCWalletHook => { } const recoverFactorWithPassword = async (password: string, storeDeviceShare: boolean = false) => { - if (mpcCoreKit && securityQuestions.isEnabled()) { + 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) - if (!factorKeyString) { - throw new Error('The password is invalid') - } const factorKey = new BN(factorKeyString, 'hex') await mpcCoreKit.inputFactorKey(factorKey) if (storeDeviceShare) { - await deviceShareModule.createAndStoreDeviceFactor() + const deviceShareRecovery = new DeviceShareRecovery(mpcCoreKit) + await deviceShareRecovery.createAndStoreDeviceFactor() } finalizeLogin()