Skip to content

Commit

Permalink
feat: signer account page to enable MFA, deviceFactor recovery on login"
Browse files Browse the repository at this point in the history
  • Loading branch information
schmanu committed Sep 27, 2023
1 parent 387b156 commit 118af61
Show file tree
Hide file tree
Showing 8 changed files with 336 additions and 12 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions src/components/settings/SignerAccountMFA/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box>
<Typography>You are currently not logged in through a social account</Typography>
</Box>
)
}

return (
<Box>
{mfaSettings?.mfaEnabled ? (
<Typography>MFA is enabled!</Typography>
) : (
<Button disabled={enablingMFA} onClick={enableMFA}>
Enable MFA
</Button>
)}
</Box>
)
}

export default SignerAccountMFA
32 changes: 32 additions & 0 deletions src/components/settings/SignerAccountMFA/useMFASettings.ts
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions src/components/sidebar/SidebarNavigation/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ export const settingsNavItems = [
label: 'Environment variables',
href: AppRoutes.settings.environmentVariables,
},
{
label: 'Signer Account',
href: AppRoutes.settings.signerAccount,
},
]

export const generalSettingsNavItems = [
Expand Down
1 change: 1 addition & 0 deletions src/config/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const AppRoutes = {
},
settings: {
spendingLimits: '/settings/spending-limits',
signerAccount: '/settings/signer-account',
setup: '/settings/setup',
modules: '/settings/modules',
index: '/settings',
Expand Down
188 changes: 188 additions & 0 deletions src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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: '[email protected]',
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: '[email protected]',
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' },
})
})
})
})
29 changes: 17 additions & 12 deletions src/hooks/wallets/mpc/useMPCWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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: {
Expand All @@ -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)
Expand Down
Loading

0 comments on commit 118af61

Please sign in to comment.