From 367af215958f9edd4cefe7d18b2421d57464496e Mon Sep 17 00:00:00 2001 From: Sergey Kintsel Date: Fri, 13 Sep 2024 09:52:14 +0100 Subject: [PATCH] Add password for backups --- apps/desktop/src/components/ErrorPage.tsx | 6 ++- .../masterPassword/MasterPassword.tsx | 17 +++++-- apps/desktop/src/utils/useSaveBackup.tsx | 37 +++++++++++++++ .../src/views/settings/SettingsView.tsx | 45 ++++++++----------- apps/web/src/components/Menu/Menu.test.tsx | 4 ++ apps/web/src/components/Menu/Menu.tsx | 5 ++- .../web/src/components/Menu/useSaveBackup.tsx | 9 ++++ .../SetupPassword/SetupPassword.tsx | 12 ++++- packages/crypto/src/AES.ts | 2 +- packages/state/src/hooks/backup.ts | 38 +++++++++++----- 10 files changed, 127 insertions(+), 48 deletions(-) create mode 100644 apps/desktop/src/utils/useSaveBackup.tsx create mode 100644 apps/web/src/components/Menu/useSaveBackup.tsx diff --git a/apps/desktop/src/components/ErrorPage.tsx b/apps/desktop/src/components/ErrorPage.tsx index 4027b4c6a5..08b98ecd0c 100644 --- a/apps/desktop/src/components/ErrorPage.tsx +++ b/apps/desktop/src/components/ErrorPage.tsx @@ -1,11 +1,11 @@ import { Box, Button, Center, Heading, Link, VStack } from "@chakra-ui/react"; -import { downloadBackupFile } from "@umami/state"; import { useOffboardingModal } from "./Offboarding/useOffboardingModal"; import { ModalContentWrapper } from "./Onboarding/ModalContentWrapper"; import { NoticeIcon, ReloadIcon } from "../assets/icons"; import BackgroundImage from "../assets/onboarding/background_image.png"; import colors from "../style/colors"; +import { useSaveBackup } from "../utils/useSaveBackup"; const feedbackEmailBodyTemplate = "What is it about? (if a bug report please consider including your account address) %0A PLEASE FILL %0A%0A What is the feedback? %0A PLEASE FILL"; @@ -16,9 +16,11 @@ const refresh = () => { export const ErrorPage = () => { const { modalElement: OffboardingModal, onOpen: onOpenOffboardingModal } = useOffboardingModal(); + const { content: saveBackupModal, onOpen: saveBackup } = useSaveBackup(); return (
+ {saveBackupModal} { title="Oops! Something went wrong!" > - diff --git a/apps/desktop/src/components/Onboarding/masterPassword/MasterPassword.tsx b/apps/desktop/src/components/Onboarding/masterPassword/MasterPassword.tsx index 67775a54c6..1e8d5a7234 100644 --- a/apps/desktop/src/components/Onboarding/masterPassword/MasterPassword.tsx +++ b/apps/desktop/src/components/Onboarding/masterPassword/MasterPassword.tsx @@ -14,9 +14,11 @@ import { EnterPassword } from "./password/EnterPassword"; export const MasterPassword = ({ account, onClose, + onVerify, }: { - account: MasterPasswordStep["account"]; - onClose: () => void; + account?: MasterPasswordStep["account"]; + onClose?: () => void; + onVerify?: (password: string) => void; }) => { const restoreFromMnemonic = useRestoreFromMnemonic(); const restoreFromSecretKey = useRestoreFromSecretKey(); @@ -25,10 +27,19 @@ export const MasterPassword = ({ const { isLoading, handleAsyncAction } = useAsyncActionHandler(); const toast = useToast(); + const handleSubmit = (password: string) => handleAsyncAction(async () => { await checkPassword?.(password); + if (onVerify) { + return onVerify(password); + } + + if (!account) { + throw new Error("No account data provided."); + } + switch (account.type) { case "secret_key": await restoreFromSecretKey(account.secretKey, password, account.label); @@ -41,7 +52,7 @@ export const MasterPassword = ({ }); } toast({ description: "Account successfully created!", status: "success" }); - onClose(); + onClose?.(); }); if (passwordHasBeenSet) { diff --git a/apps/desktop/src/utils/useSaveBackup.tsx b/apps/desktop/src/utils/useSaveBackup.tsx new file mode 100644 index 0000000000..b64d2bf785 --- /dev/null +++ b/apps/desktop/src/utils/useSaveBackup.tsx @@ -0,0 +1,37 @@ +import { + Modal, + ModalCloseButton, + ModalContent, + ModalHeader, + useDisclosure, +} from "@chakra-ui/react"; +import { downloadBackupFile } from "@umami/state"; + +import { MasterPassword } from "../components/Onboarding/masterPassword/MasterPassword"; + +export const useSaveBackup = () => { + const { isOpen, onClose, onOpen } = useDisclosure(); + + return { + content: ( + + + + + + downloadBackupFile(password).then(onClose)} + /> + + + ), + onOpen, + }; +}; diff --git a/apps/desktop/src/views/settings/SettingsView.tsx b/apps/desktop/src/views/settings/SettingsView.tsx index fe36cc67c9..bdcc71f18a 100644 --- a/apps/desktop/src/views/settings/SettingsView.tsx +++ b/apps/desktop/src/views/settings/SettingsView.tsx @@ -1,6 +1,5 @@ import { Box, Button, Flex, Heading } from "@chakra-ui/react"; import { useDynamicModalContext } from "@umami/components"; -import { downloadBackupFile } from "@umami/state"; import { type PropsWithChildren } from "react"; import { DAppsDrawerCard } from "./DAppsDrawerCard"; @@ -11,6 +10,7 @@ import { ChangePasswordForm } from "../../components/ChangePassword/ChangePasswo import { ClickableCard, SettingsCardWithDrawerIcon } from "../../components/ClickableCard"; import { useOffboardingModal } from "../../components/Offboarding/useOffboardingModal"; import { TopBar } from "../../components/TopBar"; +import { useSaveBackup } from "../../utils/useSaveBackup"; export const SettingsView = () => ( @@ -27,33 +27,28 @@ export const SettingsView = () => ( const GeneralSection = () => ( - {/* - TODO: implement this - - - Light - - Dark - - - */} ); -const BackupSection = () => ( - - - - Download backup file - - - - -); +const BackupSection = () => { + const { onOpen, content } = useSaveBackup(); + + return ( + + {content} + + + Download backup file + + + + + ); +}; const AdvancedSection = () => { const { modalElement: OffboardingModal, onOpen: onOpenOffboardingModal } = useOffboardingModal(); @@ -62,10 +57,6 @@ const AdvancedSection = () => { return ( - {/* - TODO: implement this - {}} /> - */} ", () => { await user.click(screen.getByText("Save Backup")); + await user.type(screen.getByLabelText("Set Password"), "password"); + await user.type(screen.getByLabelText("Confirm Password"), "password"); + await user.click(screen.getByRole("button", { name: "Save Backup" })); + expect(downloadBackupFile).toHaveBeenCalled(); }); diff --git a/apps/web/src/components/Menu/Menu.tsx b/apps/web/src/components/Menu/Menu.tsx index 92dc455d23..3d9acc6833 100644 --- a/apps/web/src/components/Menu/Menu.tsx +++ b/apps/web/src/components/Menu/Menu.tsx @@ -1,13 +1,13 @@ import { Switch } from "@chakra-ui/react"; import { useColorMode } from "@chakra-ui/system"; import { useDynamicDrawerContext, useDynamicModalContext } from "@umami/components"; -import { downloadBackupFile } from "@umami/state"; import { AddressBookMenu } from "./AddressBookMenu/AddressBookMenu"; import { AdvancedMenu } from "./AdvancedMenu/AdvancedMenu"; import { AppsMenu } from "./AppsMenu/AppsMenu"; import { GenericMenu } from "./GenericMenu"; import { LogoutModal } from "./LogoutModal"; +import { useSaveBackup } from "./useSaveBackup"; import { BookIcon, CodeSandboxIcon, @@ -25,6 +25,7 @@ export const Menu = () => { const { openWith: openDrawer } = useDynamicDrawerContext(); const { colorMode, toggleColorMode } = useColorMode(); const isVerified = useIsAccountVerified(); + const saveBackup = useSaveBackup(); const colorModeSwitchLabel = colorMode === "light" ? "Light mode" : "Dark mode"; @@ -43,7 +44,7 @@ export const Menu = () => { { label: "Save Backup", icon: , - onClick: downloadBackupFile, + onClick: saveBackup, }, { label: "Apps", diff --git a/apps/web/src/components/Menu/useSaveBackup.tsx b/apps/web/src/components/Menu/useSaveBackup.tsx new file mode 100644 index 0000000000..11539daac1 --- /dev/null +++ b/apps/web/src/components/Menu/useSaveBackup.tsx @@ -0,0 +1,9 @@ +import { useDynamicModalContext } from "@umami/components"; + +import { SetupPassword } from "../Onboarding/SetupPassword"; + +export const useSaveBackup = () => { + const { openWith } = useDynamicModalContext(); + + return () => openWith(); +}; diff --git a/apps/web/src/components/Onboarding/SetupPassword/SetupPassword.tsx b/apps/web/src/components/Onboarding/SetupPassword/SetupPassword.tsx index 8f733ab274..58c9a63c21 100644 --- a/apps/web/src/components/Onboarding/SetupPassword/SetupPassword.tsx +++ b/apps/web/src/components/Onboarding/SetupPassword/SetupPassword.tsx @@ -15,6 +15,7 @@ import { useDynamicModalContext, useMultiForm } from "@umami/components"; import { DEFAULT_ACCOUNT_LABEL, type MnemonicAccount } from "@umami/core"; import { accountsActions, + downloadBackupFile, generate24WordMnemonic, useAppDispatch, useAsyncActionHandler, @@ -44,7 +45,7 @@ type FormFields = { curve: Exclude; }; -type Mode = "mnemonic" | "secret_key" | "new_mnemonic" | "verification"; +type Mode = "mnemonic" | "secret_key" | "new_mnemonic" | "verification" | "save_backup"; type SetupPasswordProps = { mode: Mode; @@ -66,6 +67,12 @@ const getModeConfig = (mode: Mode) => { buttonLabel: "Create Account", subtitle: "Set a password to unlock your wallet. Make sure to store your password safely.", }; + case "save_backup": + return { + icon: LockIcon, + title: "Encrypt Backup", + buttonLabel: "Save Backup", + }; case "mnemonic": case "secret_key": return { @@ -146,6 +153,9 @@ export const SetupPassword = ({ mode }: SetupPasswordProps) => { return openWith(, { size: "xl" }); } + case "save_backup": { + await downloadBackupFile(password); + } } return onClose(); diff --git a/packages/crypto/src/AES.ts b/packages/crypto/src/AES.ts index e94d2d3bc1..3df50c73b9 100644 --- a/packages/crypto/src/AES.ts +++ b/packages/crypto/src/AES.ts @@ -18,7 +18,7 @@ export const encrypt = async (data: string, password: string): Promise backup["recoveryPhrases"] && backup["derivat const isV2Backup = (backup: any) => !!backup["persist:accounts"]; +const isV3Backup = (backup: any) => ["data", "iv", "salt"].every(key => key in backup); + export const useRestoreBackup = () => { const restoreV1 = useRestoreV1BackupFile(); @@ -18,17 +20,18 @@ export const useRestoreBackup = () => { if (isV2Backup(backup)) { return restoreV2BackupFile(backup, password, persistor); } + if (isV3Backup(backup)) { + return restoreV3BackupFile(backup, password, persistor); + } throw new Error("Invalid backup file."); }; }; +type V1Backup = { recoveryPhrases: [EncryptedData]; derivationPaths: [string] }; export const useRestoreV1BackupFile = () => { const restoreFromMnemonic = useRestoreFromMnemonic(); - return async ( - backup: { recoveryPhrases: [EncryptedData]; derivationPaths: [string] }, - password: string - ) => { + return async (backup: V1Backup, password: string) => { const encrypted: [EncryptedData] = backup["recoveryPhrases"]; // The prefix `m/` from V1 can be ignored. const derivationPaths = backup.derivationPaths.map((path: string) => @@ -53,8 +56,9 @@ export const useRestoreV1BackupFile = () => { }; }; +type V2Backup = { "persist:accounts": string; "persist:root": string }; export const restoreV2BackupFile = async ( - backup: { "persist:accounts": string; "persist:root": string }, + backup: V2Backup, password: string, persistor: Persistor ) => { @@ -80,18 +84,28 @@ export const restoreV2BackupFile = async ( window.location.reload(); }; -export const downloadBackupFile = () => { +export const restoreV3BackupFile = async ( + encryptedBackup: EncryptedData, + password: string, + persistor: Persistor +) => { + const backup = JSON.parse(await decrypt(encryptedBackup, password, "V2")) as V2Backup; + return restoreV2BackupFile(backup, password, persistor); +}; + +export const downloadBackupFile = async (password: string) => { const storage = { "persist:accounts": localStorage.getItem("persist:accounts"), "persist:root": localStorage.getItem("persist:root"), }; + const rawBackup = JSON.stringify(storage); + const encryptedBackup = await encrypt(rawBackup, password); - const downloadedDate = new Date().toISOString().slice(0, 10); - - const jsonString = `data:text/json;chatset=utf-8,${encodeURIComponent(JSON.stringify(storage))}`; const link = document.createElement("a"); - link.href = jsonString; - link.download = `UmamiV2Backup_${downloadedDate}.json`; + + const currentDate = new Date().toISOString().slice(0, 10); + link.href = `data:text/json;chatset=utf-8,${encodeURIComponent(JSON.stringify(encryptedBackup))}`; + link.download = `UmamiV2Backup_${currentDate}.json`; link.click(); };