Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: signer account page to enable MFA, deviceFactor recovery on login #2559

Merged
merged 9 commits into from
Oct 5, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
11 changes: 10 additions & 1 deletion src/components/common/ConnectWallet/MPCWallet.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
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)

console.log(walletState)

return (
<>
Expand All @@ -28,6 +33,10 @@ export const MPCWallet = () => {
)}
</Button>
)}

{walletState === MPCWalletState.MANUAL_RECOVERY && (
<PasswordRecovery recoverFactorWithPassword={recoverFactorWithPassword} />
)}
</>
)
}
2 changes: 1 addition & 1 deletion src/components/common/ConnectWallet/MPCWalletProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type MPCWalletContext = {
triggerLogin: () => Promise<void>
resetAccount: () => Promise<void>
upsertPasswordBackup: (password: string) => Promise<void>
recoverFactorWithPassword: (password: string) => Promise<void>
recoverFactorWithPassword: (password: string, storeDeviceFactor: boolean) => Promise<void>
walletState: MPCWalletState
userInfo: {
email: string | undefined
Expand Down
64 changes: 64 additions & 0 deletions src/components/common/ConnectWallet/PasswordRecovery.tsx
Original file line number Diff line number Diff line change
@@ -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<void>
}) => {
const [showPassword, setShowPassword] = useState(false)
const [recoveryPassword, setRecoveryPassword] = useState<string>('')
const [storeDeviceFactor, setStoreDeviceFactor] = useState(false)
return (
<ModalDialog open dialogTitle="Enter your recovery password" hideChainIndicator>
<DialogContent>
<Box>
<Typography>
This browser is not registered with your Account yet. Please enter your recovery password to restore access
to this account.
iamacook marked this conversation as resolved.
Show resolved Hide resolved
</Typography>
<Box mt={2} display="flex" flexDirection="column" alignItems="baseline" gap={2}>
<TextField
label="Recovery password"
type={showPassword ? 'text' : 'password'}
value={recoveryPassword}
onChange={(event) => {
setRecoveryPassword(event.target.value)
}}
InputProps={{
endAdornment: (
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword((prev) => !prev)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
),
}}
/>
<FormControlLabel
control={<Checkbox checked={storeDeviceFactor} onClick={() => setStoreDeviceFactor((prev) => !prev)} />}
label="Do not ask again on this device"
/>

<Button variant="contained" onClick={() => recoverFactorWithPassword(recoveryPassword, storeDeviceFactor)}>
Submit
</Button>
</Box>
</Box>
</DialogContent>
</ModalDialog>
)
}
164 changes: 164 additions & 0 deletions src/components/settings/SignerAccountMFA/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import useMPC from '@/hooks/wallets/mpc/useMPC'
import { Box, Button, Checkbox, FormControlLabel, TextField, Typography } from '@mui/material'
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 useMFASettings from './useMFASettings'
import { useForm } from 'react-hook-form'
import { useDeviceShare } from '@/hooks/wallets/mpc/recovery/useDeviceShare'

type SignerAccountFormData = {
oldPassword: string | undefined
newPassword: string
confirmPassword: string
storeDeviceShare: boolean
}

const SignerAccountMFA = () => {
const mpcCoreKit = useMPC()
const mfaSettings = useMFASettings(mpcCoreKit)
const securityQuestions = useSecurityQuestions(mpcCoreKit)
const deviceShareModule = useDeviceShare(mpcCoreKit)

const formMethods = useForm<SignerAccountFormData>({
mode: 'all',
iamacook marked this conversation as resolved.
Show resolved Hide resolved
})

const { register, formState, watch, setValue, handleSubmit } = formMethods

const [enablingMFA, setEnablingMFA] = useState(false)

const isPasswordSet = useMemo(() => securityQuestions.isEnabled(), [securityQuestions])

console.log(mpcCoreKit)

useEffect(() => {
deviceShareModule.isEnabled().then((value) => setValue('storeDeviceShare', value))
}, [deviceShareModule, setValue])

const enableMFA = async () => {
if (!mpcCoreKit) {
return
}

setEnablingMFA(true)
try {
const { newPassword, oldPassword, storeDeviceShare } = formMethods.getValues()
iamacook marked this conversation as resolved.
Show resolved Hide resolved
// 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 (!mfaSettings?.mfaEnabled) {
// 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 deviceShareModule.isEnabled()
if (hasDeviceShare !== storeDeviceShare) {
if (storeDeviceShare) {
schmanu marked this conversation as resolved.
Show resolved Hide resolved
await deviceShareModule.createAndStoreDeviceFactor()
} else {
// Switch to password recovery factor such that we can delete the device factor
await mpcCoreKit.inputFactorKey(new BN(securityQuestionFactor, 'hex'))

await deviceShareModule.removeDeviceFactor()
}
}
} catch (error) {
console.error(error)
iamacook marked this conversation as resolved.
Show resolved Hide resolved
} finally {
setEnablingMFA(false)
}
}

if (mpcCoreKit?.status !== COREKIT_STATUS.LOGGED_IN) {
return (
<Box>
<Typography>You are currently not logged in with a social account</Typography>
</Box>
)
}

const onSubmit = async () => {
console.log('submitting')
await enableMFA()
}

return (
<form onSubmit={handleSubmit(onSubmit)}>
<Box display="flex" flexDirection="column" gap={3} alignItems="baseline">
{
/* TODO: Memoize this*/ securityQuestions.isEnabled() ? (
<Typography>You already have a recovery password setup.</Typography>
) : (
<Typography>You have no password setup. Secure your account now!</Typography>
)
}
{isPasswordSet && (
<TextField
placeholder="Old password"
label="Old password"
type="password"
error={!!formState.errors['oldPassword']}
helperText={formState.errors['oldPassword']?.message}
{...register('oldPassword', {
required: securityQuestions.isEnabled(),
})}
/>
)}
<TextField
placeholder="New password"
label="New password"
type="password"
error={!!formState.errors['newPassword']}
helperText={formState.errors['newPassword']?.message}
{...register('newPassword', {
required: true,
minLength: 6,
})}
/>
<TextField
placeholder="Confirm new password"
label="Confirm new password"
type="password"
error={!!formState.errors['confirmPassword']}
helperText={formState.errors['confirmPassword']?.message}
{...register('confirmPassword', {
required: true,
validate: (value: string) => {
const currentNewPW = watch('newPassword')
iamacook marked this conversation as resolved.
Show resolved Hide resolved
if (value !== currentNewPW) {
return 'Passwords do not match'
}
},
})}
/>

<FormControlLabel
control={<Checkbox {...register('storeDeviceShare')} />}
label="Do not ask for second factor on this device"
/>

<Button
sx={{ justifySelf: 'flex-start' }}
disabled={!formMethods.formState.isValid || enablingMFA}
type="submit"
>
Change
</Button>
</Box>
</form>
)
}

export default SignerAccountMFA
21 changes: 21 additions & 0 deletions src/components/settings/SignerAccountMFA/useMFASettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { COREKIT_STATUS, type Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit'

export type MFASettings = {
mfaEnabled: boolean
} | null

const useMFASettings = (mpcCoreKit: Web3AuthMPCCoreKit | undefined) => {
if (mpcCoreKit?.status !== COREKIT_STATUS.LOGGED_IN) {
return null
}

const { shareDescriptions } = mpcCoreKit?.getKeyDetails()
iamacook marked this conversation as resolved.
Show resolved Hide resolved

const isMFAEnabled = !Object.entries(shareDescriptions).some(([key, value]) => value[0]?.includes('hashedShare'))
iamacook marked this conversation as resolved.
Show resolved Hide resolved

return {
mfaEnabled: isMFAEnabled,
}
}

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',
iamacook marked this conversation as resolved.
Show resolved Hide resolved
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
8 changes: 4 additions & 4 deletions src/hooks/wallets/mpc/__tests__/useMPC.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -104,7 +104,7 @@ describe('useInitMPC', () => {
renderHook(() => useInitMPC())

await waitFor(() => {
expect(getMPCCoreKitInstance()).toBeDefined()
expect(_getMPCCoreKitInstance()).toBeDefined()
expect(connectWalletSpy).not.toBeCalled()
})
})
Expand Down Expand Up @@ -151,7 +151,7 @@ describe('useInitMPC', () => {

await waitFor(() => {
expect(connectWalletSpy).toBeCalled()
expect(getMPCCoreKitInstance()).toBeDefined()
expect(_getMPCCoreKitInstance()).toBeDefined()
})
})

Expand Down Expand Up @@ -215,7 +215,7 @@ describe('useInitMPC', () => {

await waitFor(() => {
expect(mockChainChangedListener).toHaveBeenCalledWith('0x5')
expect(getMPCCoreKitInstance()).toBeDefined()
expect(_getMPCCoreKitInstance()).toBeDefined()
expect(connectWalletSpy).not.toBeCalled()
})
})
Expand Down
Loading
Loading