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 all 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
9 changes: 8 additions & 1 deletion src/components/common/ConnectWallet/MPCWallet.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
Expand All @@ -28,6 +31,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.
</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>
)
}
44 changes: 13 additions & 31 deletions src/components/new-safe/create/steps/ConnectWalletStep/index.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
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'
import useSyncSafeCreationStep from '@/components/new-safe/create/useSyncSafeCreationStep'
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<NewSafeFormData>) => {
const [pendingSafe] = usePendingSafe()
const wallet = useWallet()
const chain = useCurrentChain()
const isSupported = isPairingSupported(chain?.disabledWallets)
const handleConnect = useConnectWallet()
const [, setSubmitted] = useState(false)
useSyncSafeCreationStep(setStep)
Expand All @@ -37,33 +31,21 @@ const ConnectWalletStep = ({ onSubmit, setStep }: StepRenderProps<NewSafeFormDat
return (
<>
<Box className={layoutCss.row}>
<Grid container spacing={3}>
<Grid item xs={12} md={6} display="flex" flexDirection="column" alignItems="center" gap={2}>
<Box width={100} height={100} display="flex" alignItems="center" justifyContent="center">
<KeyholeIcon />
</Box>
<Box display="flex" flexDirection="column" alignItems="center" gap={2}>
<Box width={100} height={100} display="flex" alignItems="center" justifyContent="center">
<KeyholeIcon />
</Box>

<Button onClick={handleConnect} variant="contained" size="stretched" disableElevation>
Connect
</Button>
<Button onClick={handleConnect} variant="contained" size="stretched" disableElevation>
Connect
</Button>

<Divider sx={{ width: '100%' }}>
<Typography color="primary">or</Typography>
</Divider>
<Divider sx={{ width: '100%' }}>
<Typography color="primary">or</Typography>
</Divider>

<MPCWallet />
</Grid>

{isSupported && (
<Grid item xs={12} md={6} display="flex" flexDirection="column" alignItems="center" gap={2}>
<PairingQRCode />
<Typography variant="h6" fontWeight="700">
Connect to {'Safe{Wallet}'} mobile
</Typography>
<PairingDescription />
</Grid>
)}
</Grid>
<MPCWallet />
</Box>
</Box>
</>
)
Expand Down
124 changes: 124 additions & 0 deletions src/components/settings/SignerAccountMFA/PasswordForm.tsx
Original file line number Diff line number Diff line change
@@ -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<PasswordFormData>({
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 (
<form onSubmit={handleSubmit(onSubmit)}>
<Box display="flex" flexDirection="column" gap={3} alignItems="baseline">
{isPasswordSet ? (
<Typography>You already have a recovery password setup.</Typography>
) : (
<Typography>You have no password setup. We suggest adding one to secure your Account.</Typography>
)}
{isPasswordSet && (
<TextField
placeholder="Old password"
label="Old password"
type="password"
error={!!formState.errors[PasswordFieldNames.oldPassword]}
helperText={formState.errors[PasswordFieldNames.oldPassword]?.message}
{...register(PasswordFieldNames.oldPassword, {
required: true,
})}
/>
)}
<TextField
placeholder="New password"
label="New password"
type="password"
error={!!formState.errors[PasswordFieldNames.newPassword]}
helperText={formState.errors[PasswordFieldNames.newPassword]?.message}
{...register(PasswordFieldNames.newPassword, {
required: true,
minLength: 6,
})}
/>
<TextField
placeholder="Confirm new password"
label="Confirm new password"
type="password"
error={!!formState.errors[PasswordFieldNames.confirmPassword]}
helperText={formState.errors[PasswordFieldNames.confirmPassword]?.message}
{...register(PasswordFieldNames.confirmPassword, {
required: true,
validate: (value: string) => {
const currentNewPW = getValues(PasswordFieldNames.newPassword)
if (value !== currentNewPW) {
return 'Passwords do not match'
}
},
})}
/>

<Controller
control={control}
name={PasswordFieldNames.storeDeviceShare}
render={({ field: { value, ...field } }) => (
<FormControlLabel
control={<Checkbox checked={value ?? false} {...field} />}
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>
)
}
68 changes: 68 additions & 0 deletions src/components/settings/SignerAccountMFA/helper.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 21 additions & 0 deletions src/components/settings/SignerAccountMFA/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box>
<Typography>You are currently not logged in with a social account</Typography>
</Box>
)
}

return <PasswordForm mpcCoreKit={mpcCoreKit} />
}

export default SignerAccountMFA
Loading