diff --git a/src/constants/pages/index.ts b/src/constants/pages/index.ts index e8612b3fd..cd5f3b0e6 100644 --- a/src/constants/pages/index.ts +++ b/src/constants/pages/index.ts @@ -9,6 +9,7 @@ export enum PAGES { SIGNUP = '/signup', TWO_FACTOR_SETUP = '/two-factor/setup', TWO_FACTOR_VERIFY = '/two-factor/verify', + TWO_FACTOR_RECOVER = '/two-factor/recover', VERIFY = '/verify', ROOT = '/', SHARED_ALBUMS = '/shared-albums', diff --git a/src/pages/two-factor/recover/index.tsx b/src/pages/two-factor/recover/index.tsx new file mode 100644 index 000000000..99cbc17b6 --- /dev/null +++ b/src/pages/two-factor/recover/index.tsx @@ -0,0 +1,132 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { getData, LS_KEYS, setData } from 'utils/storage/localStorage'; +import { useRouter } from 'next/router'; +import SingleInputForm, { + SingleInputFormProps, +} from 'components/SingleInputForm'; +import VerticallyCentered from 'components/Container'; +import { logError } from 'utils/sentry'; +import { recoverTwoFactor, removeTwoFactor } from 'services/userService'; +import { AppContext } from 'pages/_app'; +import { PAGES } from 'constants/pages'; +import FormPaper from 'components/Form/FormPaper'; +import FormPaperTitle from 'components/Form/FormPaper/Title'; +import FormPaperFooter from 'components/Form/FormPaper/Footer'; +import LinkButton from 'components/pages/gallery/LinkButton'; +import { B64EncryptionResult } from 'types/crypto'; +import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker'; +import { t } from 'i18next'; +import { Trans } from 'react-i18next'; +import { Link } from '@mui/material'; +import { SUPPORT_EMAIL } from 'constants/urls'; + +const bip39 = require('bip39'); +// mobile client library only supports english. +bip39.setDefaultWordlist('english'); + +export default function Recover() { + const router = useRouter(); + const [encryptedTwoFactorSecret, setEncryptedTwoFactorSecret] = + useState(null); + const [sessionID, setSessionID] = useState(null); + const appContext = useContext(AppContext); + + useEffect(() => { + router.prefetch(PAGES.GALLERY); + const user = getData(LS_KEYS.USER); + if (!user.isTwoFactorEnabled && (user.encryptedToken || user.token)) { + router.push(PAGES.GENERATE); + } else if (!user.email || !user.twoFactorSessionID) { + router.push(PAGES.ROOT); + } else { + setSessionID(user.twoFactorSessionID); + } + const main = async () => { + const resp = await recoverTwoFactor(user.twoFactorSessionID); + setEncryptedTwoFactorSecret({ + encryptedData: resp.encryptedSecret, + nonce: resp.secretDecryptionNonce, + key: null, + }); + }; + main(); + }, []); + + const recover: SingleInputFormProps['callback'] = async ( + recoveryKey: string, + setFieldError + ) => { + try { + recoveryKey = recoveryKey + .trim() + .split(' ') + .map((part) => part.trim()) + .filter((part) => !!part) + .join(' '); + // check if user is entering mnemonic recovery key + if (recoveryKey.indexOf(' ') > 0) { + if (recoveryKey.split(' ').length !== 24) { + throw new Error('recovery code should have 24 words'); + } + recoveryKey = bip39.mnemonicToEntropy(recoveryKey); + } + const cryptoWorker = await ComlinkCryptoWorker.getInstance(); + const twoFactorSecret = await cryptoWorker.decryptB64( + encryptedTwoFactorSecret.encryptedData, + encryptedTwoFactorSecret.nonce, + await cryptoWorker.fromHex(recoveryKey) + ); + const resp = await removeTwoFactor(sessionID, twoFactorSecret); + const { keyAttributes, encryptedToken, token, id } = resp; + setData(LS_KEYS.USER, { + ...getData(LS_KEYS.USER), + token, + encryptedToken, + id, + isTwoFactorEnabled: false, + }); + setData(LS_KEYS.KEY_ATTRIBUTES, keyAttributes); + router.push(PAGES.CREDENTIALS); + } catch (e) { + logError(e, 'two factor recovery failed'); + setFieldError(t('INCORRECT_RECOVERY_KEY')); + } + }; + + const showNoRecoveryKeyMessage = () => { + appContext.setDialogMessage({ + title: t('CONTACT_SUPPORT'), + close: {}, + content: ( + , + }} + /> + ), + }); + }; + + return ( + + + {t('RECOVER_TWO_FACTOR')} + + + + {t('NO_RECOVERY_KEY')} + + + {t('GO_BACK')} + + + + + ); +} diff --git a/src/pages/two-factor/setup/index.tsx b/src/pages/two-factor/setup/index.tsx index c5cdca7c4..dba6aee19 100644 --- a/src/pages/two-factor/setup/index.tsx +++ b/src/pages/two-factor/setup/index.tsx @@ -7,6 +7,7 @@ import { useRouter } from 'next/router'; import VerifyTwoFactor, { VerifyTwoFactorCallback, } from 'components/TwoFactor/VerifyForm'; +import { encryptWithRecoveryKey } from 'utils/crypto'; import { setData, LS_KEYS, getData } from 'utils/storage/localStorage'; import { PAGES } from 'constants/pages'; import { TwoFactorSecret } from 'types/user'; @@ -45,7 +46,10 @@ export default function SetupTwoFactor() { otp: string, markSuccessful ) => { - await enableTwoFactor(otp); + const recoveryEncryptedTwoFactorSecret = await encryptWithRecoveryKey( + twoFactorSecret.secretCode + ); + await enableTwoFactor(otp, recoveryEncryptedTwoFactorSecret); await markSuccessful(); setData(LS_KEYS.USER, { ...getData(LS_KEYS.USER), diff --git a/src/pages/two-factor/verify/index.tsx b/src/pages/two-factor/verify/index.tsx index 416590c27..df176d667 100644 --- a/src/pages/two-factor/verify/index.tsx +++ b/src/pages/two-factor/verify/index.tsx @@ -2,13 +2,11 @@ import VerifyTwoFactor, { VerifyTwoFactorCallback, } from 'components/TwoFactor/VerifyForm'; import router from 'next/router'; -import React, { useContext, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { logoutUser, verifyTwoFactor } from 'services/userService'; -import { AppContext } from 'pages/_app'; import { PAGES } from 'constants/pages'; import { User } from 'types/user'; import { setData, LS_KEYS, getData } from 'utils/storage/localStorage'; -import { Trans } from 'react-i18next'; import { t } from 'i18next'; import LinkButton from 'components/pages/gallery/LinkButton'; @@ -16,12 +14,9 @@ import FormContainer from 'components/Form/FormContainer'; import FormPaper from 'components/Form/FormPaper'; import FormTitle from 'components/Form/FormPaper/Title'; import FormPaperFooter from 'components/Form/FormPaper/Footer'; -import { Link } from '@mui/material'; -import { SUPPORT_EMAIL } from 'constants/urls'; export default function Home() { const [sessionID, setSessionID] = useState(''); - const appContext = useContext(AppContext); useEffect(() => { const main = async () => { @@ -41,22 +36,6 @@ export default function Home() { main(); }, []); - const showContactSupport = () => { - appContext.setDialogMessage({ - title: t('CONTACT_SUPPORT'), - close: {}, - content: ( - , - }} - values={{ emailID: SUPPORT_EMAIL }} - /> - ), - }); - }; - const onSubmit: VerifyTwoFactorCallback = async (otp) => { try { const resp = await verifyTwoFactor(otp, sessionID); @@ -84,7 +63,8 @@ export default function Home() { - + router.push(PAGES.TWO_FACTOR_RECOVER)}> {t('LOST_DEVICE')} diff --git a/src/services/userService.ts b/src/services/userService.ts index 1d01af508..972068aa9 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -15,6 +15,7 @@ import { RecoveryKey, TwoFactorSecret, TwoFactorVerificationResponse, + TwoFactorRecoveryResponse, UserDetails, DeleteChallengeResponse, GetRemoteStoreValueResponse, @@ -23,6 +24,7 @@ import { ServerErrorCodes } from 'utils/error'; import isElectron from 'is-electron'; import safeStorageService from './electron/safeStorage'; import { deleteAllCache } from 'utils/storage/cache'; +import { B64EncryptionResult } from 'types/crypto'; import { getLocalFamilyData, isPartOfFamily } from 'utils/user/family'; import { AxiosResponse } from 'axios'; @@ -217,11 +219,18 @@ export const setupTwoFactor = async () => { return resp.data as TwoFactorSecret; }; -export const enableTwoFactor = async (code: string) => { +export const enableTwoFactor = async ( + code: string, + recoveryEncryptedTwoFactorSecret: B64EncryptionResult +) => { await HTTPService.post( `${ENDPOINT}/users/two-factor/enable`, { code, + encryptedTwoFactorSecret: + recoveryEncryptedTwoFactorSecret.encryptedData, + twoFactorSecretDecryptionNonce: + recoveryEncryptedTwoFactorSecret.nonce, }, null, { @@ -242,6 +251,21 @@ export const verifyTwoFactor = async (code: string, sessionID: string) => { return resp.data as TwoFactorVerificationResponse; }; +export const recoverTwoFactor = async (sessionID: string) => { + const resp = await HTTPService.get(`${ENDPOINT}/users/two-factor/recover`, { + sessionID, + }); + return resp.data as TwoFactorRecoveryResponse; +}; + +export const removeTwoFactor = async (sessionID: string, secret: string) => { + const resp = await HTTPService.post(`${ENDPOINT}/users/two-factor/remove`, { + sessionID, + secret, + }); + return resp.data as TwoFactorVerificationResponse; +}; + export const disableTwoFactor = async () => { await HTTPService.post(`${ENDPOINT}/users/two-factor/disable`, null, null, { 'X-Auth-Token': getToken(), diff --git a/src/types/user/index.ts b/src/types/user/index.ts index 5f8c1e62b..a02bfc0f5 100644 --- a/src/types/user/index.ts +++ b/src/types/user/index.ts @@ -61,6 +61,11 @@ export interface TwoFactorSecret { qrCode: string; } +export interface TwoFactorRecoveryResponse { + encryptedSecret: string; + secretDecryptionNonce: string; +} + export interface FamilyMember { email: string; usage: number;