Skip to content

Commit

Permalink
Add password for backups
Browse files Browse the repository at this point in the history
  • Loading branch information
serjonya-trili committed Sep 16, 2024
1 parent 8439c72 commit 367af21
Show file tree
Hide file tree
Showing 10 changed files with 127 additions and 48 deletions.
6 changes: 4 additions & 2 deletions apps/desktop/src/components/ErrorPage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,9 +16,11 @@ const refresh = () => {

export const ErrorPage = () => {
const { modalElement: OffboardingModal, onOpen: onOpenOffboardingModal } = useOffboardingModal();
const { content: saveBackupModal, onOpen: saveBackup } = useSaveBackup();

return (
<Center height="100vh" padding="60px" backgroundImage={BackgroundImage} backgroundSize="cover">
{saveBackupModal}
<Box
width="480px"
padding="40px"
Expand All @@ -34,7 +36,7 @@ export const ErrorPage = () => {
title="Oops! Something went wrong!"
>
<VStack width="100%" spacing="16px">
<Button width="100%" borderRadius="4px" onClick={downloadBackupFile} size="lg">
<Button width="100%" borderRadius="4px" onClick={saveBackup} size="lg">
Save Backup
</Button>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -41,7 +52,7 @@ export const MasterPassword = ({
});
}
toast({ description: "Account successfully created!", status: "success" });
onClose();
onClose?.();
});

if (passwordHasBeenSet) {
Expand Down
37 changes: 37 additions & 0 deletions apps/desktop/src/utils/useSaveBackup.tsx
Original file line number Diff line number Diff line change
@@ -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: (
<Modal
autoFocus={false}
closeOnOverlayClick={false}
isCentered
isOpen={isOpen}
onClose={onClose}
>
<ModalContent>
<ModalHeader>
<ModalCloseButton />
</ModalHeader>
<MasterPassword
onClose={onClose}
onVerify={password => downloadBackupFile(password).then(onClose)}
/>
</ModalContent>
</Modal>
),
onOpen,
};
};
45 changes: 18 additions & 27 deletions apps/desktop/src/views/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 = () => (
<Flex flexDirection="column" height="100%">
Expand All @@ -27,33 +27,28 @@ export const SettingsView = () => (

const GeneralSection = () => (
<SectionContainer title="General">
{/*
TODO: implement this
<SettingsCard left="Theme">
<Flex alignItems="center" justifyContent="space-between">
<Text size="sm">Light</Text>
<Switch marginX={3} isChecked isDisabled />
<Heading size="sm">Dark</Heading>
</Flex>
</SettingsCard>
*/}
<NetworkSettingsDrawerCard />
<ErrorLogsDrawerCard />
</SectionContainer>
);

const BackupSection = () => (
<SectionContainer title="Backup">
<ClickableCard isSelected={false} onClick={downloadBackupFile}>
<Flex alignItems="center" justifyContent="space-between">
<Heading size="sm">Download backup file</Heading>
<Button onClick={downloadBackupFile} variant="unstyled">
<DownloadIcon cursor="pointer" />
</Button>
</Flex>
</ClickableCard>
</SectionContainer>
);
const BackupSection = () => {
const { onOpen, content } = useSaveBackup();

return (
<SectionContainer title="Backup">
{content}
<ClickableCard isSelected={false} onClick={onOpen}>
<Flex alignItems="center" justifyContent="space-between">
<Heading size="sm">Download backup file</Heading>
<Button onClick={onOpen} variant="unstyled">
<DownloadIcon cursor="pointer" />
</Button>
</Flex>
</ClickableCard>
</SectionContainer>
);
};

const AdvancedSection = () => {
const { modalElement: OffboardingModal, onOpen: onOpenOffboardingModal } = useOffboardingModal();
Expand All @@ -62,10 +57,6 @@ const AdvancedSection = () => {
return (
<SectionContainer title="Advanced Settings">
<DAppsDrawerCard />
{/*
TODO: implement this
<SettingsCardWithDrawerIcon left="Reset Settings" onClick={() => {}} />
*/}
<SettingsCardWithDrawerIcon
left="Off-board Wallet"
isSelected={false}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/Menu/Menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ describe("<Menu />", () => {

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();
});

Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/Menu/Menu.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";

Expand All @@ -43,7 +44,7 @@ export const Menu = () => {
{
label: "Save Backup",
icon: <DownloadIcon />,
onClick: downloadBackupFile,
onClick: saveBackup,
},
{
label: "Apps",
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/Menu/useSaveBackup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useDynamicModalContext } from "@umami/components";

import { SetupPassword } from "../Onboarding/SetupPassword";

export const useSaveBackup = () => {
const { openWith } = useDynamicModalContext();

return () => openWith(<SetupPassword mode="save_backup" />);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -44,7 +45,7 @@ type FormFields = {
curve: Exclude<Curves, "bip25519">;
};

type Mode = "mnemonic" | "secret_key" | "new_mnemonic" | "verification";
type Mode = "mnemonic" | "secret_key" | "new_mnemonic" | "verification" | "save_backup";

type SetupPasswordProps = {
mode: Mode;
Expand All @@ -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 {
Expand Down Expand Up @@ -146,6 +153,9 @@ export const SetupPassword = ({ mode }: SetupPasswordProps) => {

return openWith(<ImportantNoticeModal mnemonic={mnemonic} />, { size: "xl" });
}
case "save_backup": {
await downloadBackupFile(password);
}
}

return onClose();
Expand Down
2 changes: 1 addition & 1 deletion packages/crypto/src/AES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const encrypt = async (data: string, password: string): Promise<Encrypted
const encrypted = await crypto.subtle.encrypt(
{
name: AES_MODE,
iv: iv,
iv,
},
derivedKey,
Buffer.from(data, "utf-8")
Expand Down
38 changes: 26 additions & 12 deletions packages/state/src/hooks/backup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DEFAULT_ACCOUNT_LABEL } from "@umami/core";
import { type EncryptedData, decrypt } from "@umami/crypto";
import { type EncryptedData, decrypt, encrypt } from "@umami/crypto";
import { type Persistor } from "redux-persist";

import { useRestoreFromMnemonic } from "./setAccountData";
Expand All @@ -8,6 +8,8 @@ const isV1Backup = (backup: any) => 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();

Expand All @@ -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) =>
Expand All @@ -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
) => {
Expand All @@ -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();
};

1 comment on commit 367af21

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Title Lines Statements Branches Functions
apps/desktop Coverage: 83%
83.63% (1758/2102) 78.64% (825/1049) 78.45% (448/571)
apps/web Coverage: 83%
83.63% (1758/2102) 78.64% (825/1049) 78.45% (448/571)
packages/components Coverage: 97%
97.1% (134/138) 96.49% (55/57) 82.92% (34/41)
packages/core Coverage: 82%
82.89% (223/269) 73.18% (101/138) 81.35% (48/59)
packages/crypto Coverage: 100%
100% (28/28) 100% (3/3) 100% (5/5)
packages/data-polling Coverage: 98%
96.55% (140/145) 95.45% (21/22) 92.85% (39/42)
packages/multisig Coverage: 98%
98.47% (129/131) 89.47% (17/19) 100% (35/35)
packages/social-auth Coverage: 100%
100% (21/21) 100% (11/11) 100% (3/3)
packages/state Coverage: 83%
83.36% (777/932) 80.48% (165/205) 78.13% (293/375)
packages/tezos Coverage: 86%
85.57% (89/104) 89.47% (17/19) 82.75% (24/29)
packages/tzkt Coverage: 86%
84.05% (58/69) 81.25% (13/16) 76.92% (30/39)

Please sign in to comment.