encrypt / decrypt

This commit is contained in:
Paul Miller
2023-07-03 14:11:02 -05:00
parent c33e542932
commit d8467ca1bb
12 changed files with 387 additions and 54 deletions

View File

@@ -6,7 +6,7 @@ import { A } from "solid-start";
import { OnboardWarning } from "~/components/OnboardWarning";
import { CombinedActivity } from "./Activity";
import { useMegaStore } from "~/state/megaStore";
import { Match, Show, Switch, createMemo } from "solid-js";
import { Match, Show, Switch } from "solid-js";
import { ExternalLink } from "./layout/ExternalLink";
import { BetaWarningModal } from "~/components/BetaWarningModal";
import settings from "~/assets/icons/settings.svg";
@@ -14,6 +14,7 @@ import pixelLogo from "~/assets/mutiny-pixel-logo.png";
import plusLogo from "~/assets/mutiny-plus-logo.png";
import { PendingNwc } from "./PendingNwc";
import { useI18n } from "~/i18n/context";
import { DecryptDialog } from "./DecryptDialog";
export default function App() {
const [state, _actions] = useMegaStore();
@@ -96,6 +97,7 @@ export default function App() {
</span>
</p>
</DefaultMain>
<DecryptDialog />
<BetaWarningModal />
<NavBar activeTab="home" />
</SafeArea>

View File

@@ -0,0 +1,74 @@
import { Show, createSignal } from "solid-js";
import { Button, SimpleDialog } from "~/components/layout";
import { TextField } from "~/components/layout/TextField";
import { InfoBox } from "~/components/InfoBox";
import { useMegaStore } from "~/state/megaStore";
import eify from "~/utils/eify";
import { A } from "solid-start";
export function DecryptDialog() {
const [state, actions] = useMegaStore();
const [password, setPassword] = createSignal("");
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal("");
async function decrypt(e: Event) {
e.preventDefault();
setLoading(true);
try {
await actions.setupMutinyWallet(undefined, password());
// If we get this far and the state stills wants a password that means the password was wrong
if (state.needs_password) {
throw new Error("wrong");
}
} catch (e) {
const err = eify(e);
console.error(e);
if (err.message === "wrong") {
setError("Invalid password");
} else {
throw e;
}
} finally {
setLoading(false);
}
}
function noop() {
// noop
}
return (
<SimpleDialog
title="Enter your password"
// Only show the dialog if we need a password and there's no setup error
open={state.needs_password && !state.setup_error}
>
<form onSubmit={decrypt}>
<div class="flex flex-col gap-4">
<TextField
name="password"
type="password"
ref={noop}
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
error={""}
onBlur={noop}
onChange={noop}
/>
<Show when={error()}>
<InfoBox accent="red">{error()}</InfoBox>
</Show>
<Button intent="blue" loading={loading()} onClick={decrypt}>
Decrypt Wallet
</Button>
</div>
</form>
<A class="self-end text-m-grey-400" href="/settings/restore">
Forgot Password?
</A>
</SimpleDialog>
);
}

View File

@@ -1,19 +1,64 @@
import { useMegaStore } from "~/state/megaStore";
import { Button, InnerCard, NiceP, VStack } from "~/components/layout";
import { createSignal } from "solid-js";
import {
Button,
InnerCard,
NiceP,
SimpleDialog,
VStack
} from "~/components/layout";
import { Show, createSignal } from "solid-js";
import eify from "~/utils/eify";
import { showToast } from "./Toaster";
import { downloadTextFile } from "~/utils/download";
import { createFileUploader } from "@solid-primitives/upload";
import { ConfirmDialog } from "./Dialog";
import initMutinyWallet, { MutinyWallet } from "@mutinywallet/mutiny-wasm";
import { InfoBox } from "./InfoBox";
import { TextField } from "./layout/TextField";
export function ImportExport(props: { emergency?: boolean }) {
const [state, _] = useMegaStore();
const [error, setError] = createSignal<Error>();
const [exportDecrypt, setExportDecrypt] = createSignal(false);
const [password, setPassword] = createSignal("");
async function handleSave() {
const json = await MutinyWallet.export_json();
downloadTextFile(json || "", "mutiny-state.json");
try {
setError(undefined);
const json = await MutinyWallet.export_json();
downloadTextFile(json || "", "mutiny-state.json");
} catch (e) {
console.error(e);
const err = eify(e);
if (err.message === "Incorrect password entered.") {
setExportDecrypt(true);
} else {
setError(err);
}
}
}
async function savePassword(e: Event) {
e.preventDefault();
try {
setError(undefined);
if (!password()) {
throw new Error("Password is required");
}
const json = await MutinyWallet.export_json(password());
downloadTextFile(json || "", "mutiny-state.json");
} catch (e) {
console.error(e);
setError(eify(e));
} finally {
setExportDecrypt(false);
setPassword("");
}
}
function noop() {
// noop
}
const { files, selectFiles } = createFileUploader();
@@ -23,6 +68,7 @@ export function ImportExport(props: { emergency?: boolean }) {
const fileReader = new FileReader();
try {
setError(undefined);
const file: File = files()[0].file;
const text = await new Promise<string | null>((resolve, reject) => {
@@ -45,6 +91,7 @@ export function ImportExport(props: { emergency?: boolean }) {
await state.mutiny_wallet.stop();
} catch (e) {
console.error(e);
setError(eify(e));
}
} else {
// If there's no mutiny wallet loaded we need to initialize WASM
@@ -96,6 +143,9 @@ export function ImportExport(props: { emergency?: boolean }) {
to make sure you don't create conflicts.
</NiceP>
<div />
<Show when={error()}>
<InfoBox accent="red">{error()?.message}</InfoBox>
</Show>
<VStack>
<Button onClick={handleSave}>Save State As File</Button>
<Button onClick={uploadFile}>Import State From File</Button>
@@ -109,6 +159,32 @@ export function ImportExport(props: { emergency?: boolean }) {
>
Do you want to replace your state with {files()[0].name}?
</ConfirmDialog>
{/* TODO: this is pretty redundant with the DecryptDialog, could make a shared component */}
<SimpleDialog
title="Enter your password to decrypt"
open={exportDecrypt()}
>
<form onSubmit={savePassword}>
<div class="flex flex-col gap-4">
<TextField
name="password"
type="password"
ref={noop}
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
error={""}
onBlur={noop}
onChange={noop}
/>
<Show when={error()}>
<InfoBox accent="red">{error()?.message}</InfoBox>
</Show>
<Button intent="blue" onClick={savePassword}>
Decrypt Wallet
</Button>
</div>
</form>
</SimpleDialog>
</>
);
}

View File

@@ -61,7 +61,9 @@ export function TextField(props: TextFieldProps) {
class="w-full p-2 rounded-lg bg-white/10 placeholder-neutral-400"
/>
</Show>
<KTextField.ErrorMessage>{props.error}</KTextField.ErrorMessage>
<KTextField.ErrorMessage class="text-m-red">
{props.error}
</KTextField.ErrorMessage>
<Show when={props.caption}>
<TinyText>{props.caption}</TinyText>
</Show>

View File

@@ -18,6 +18,7 @@ import { generateGradient } from "~/utils/gradientHash";
import close from "~/assets/icons/close.svg";
import { A } from "solid-start";
import down from "~/assets/icons/down.svg";
import { DecryptDialog } from "../DecryptDialog";
export { Button, ButtonLink, Linkify };
@@ -110,7 +111,6 @@ export const Collapser: ParentComponent<{
);
};
export const SafeArea: ParentComponent = (props) => {
return (
<div class="h-[100dvh] safe-left safe-right">
@@ -161,6 +161,7 @@ export const MutinyWalletGuard: ParentComponent = (props) => {
<Show when={state.mutiny_wallet && !state.wallet_loading}>
{props.children}
</Show>
<DecryptDialog />
</Suspense>
);
};
@@ -318,7 +319,7 @@ export function ModalCloseButton() {
);
}
export const SIMPLE_OVERLAY = "fixed inset-0 z-50 bg-black/70 backdrop-blur-md";
export const SIMPLE_OVERLAY = "fixed inset-0 z-50 bg-black/20 backdrop-blur-md";
export const SIMPLE_DIALOG_POSITIONER =
"fixed inset-0 z-50 flex items-center justify-center";
export const SIMPLE_DIALOG_CONTENT =
@@ -327,10 +328,13 @@ export const SIMPLE_DIALOG_CONTENT =
export const SimpleDialog: ParentComponent<{
title: string;
open: boolean;
setOpen: (open: boolean) => void;
setOpen?: (open: boolean) => void;
}> = (props) => {
return (
<Dialog.Root open={props.open} onOpenChange={props.setOpen}>
<Dialog.Root
open={props.open}
onOpenChange={props.setOpen && props.setOpen}
>
<Dialog.Portal>
<Dialog.Overlay class={SIMPLE_OVERLAY} />
<div class={SIMPLE_DIALOG_POSITIONER}>
@@ -339,9 +343,11 @@ export const SimpleDialog: ParentComponent<{
<Dialog.Title>
<SmallHeader>{props.title}</SmallHeader>
</Dialog.Title>
<Dialog.CloseButton>
<ModalCloseButton />
</Dialog.CloseButton>
<Show when={props.setOpen}>
<Dialog.CloseButton>
<ModalCloseButton />
</Dialog.CloseButton>
</Show>
</div>
<Dialog.Description class="flex flex-col gap-4">
{props.children}

View File

@@ -106,7 +106,8 @@ export async function checkForWasm() {
}
export async function setupMutinyWallet(
settings?: MutinyWalletSettingStrings
settings?: MutinyWalletSettingStrings,
password?: string
): Promise<MutinyWallet> {
// Ultimate defense against getting multiple instances of the wallet running.
// If we detect that the wallet has already been initialized in this session, we'll reload the page.
@@ -138,7 +139,7 @@ export async function setupMutinyWallet(
console.log("Using subscriptions address", subscriptions);
const mutinyWallet = await new MutinyWallet(
// Password
"",
password ? password : undefined,
// Mnemonic
undefined,
proxy,

View File

@@ -55,10 +55,13 @@ export default function Backup() {
const [hasSeenBackup, setHasSeenBackup] = createSignal(false);
const [hasCheckedAll, setHasCheckedAll] = createSignal(false);
const [loading, setLoading] = createSignal(false);
function wroteDownTheWords() {
setLoading(true);
actions.setHasBackedUp();
navigate("/");
navigate("/settings/encrypt");
setLoading(false);
}
return (
@@ -93,6 +96,7 @@ export default function Backup() {
disabled={!hasSeenBackup() || !hasCheckedAll()}
intent="blue"
onClick={wroteDownTheWords}
loading={loading()}
>
I wrote down the words
</Button>

View File

@@ -0,0 +1,140 @@
import {
Button,
DefaultMain,
LargeHeader,
NiceP,
MutinyWalletGuard,
SafeArea,
VStack,
ButtonLink
} from "~/components/layout";
import NavBar from "~/components/NavBar";
import { useMegaStore } from "~/state/megaStore";
import { Show, createSignal } from "solid-js";
import { BackLink } from "~/components/layout/BackLink";
import { createForm } from "@modular-forms/solid";
import { TextField } from "~/components/layout/TextField";
import { timeout } from "~/utils/timeout";
import eify from "~/utils/eify";
import { InfoBox } from "~/components/InfoBox";
type EncryptPasswordForm = {
existingPassword: string;
password: string;
confirmPassword: string;
};
export default function Encrypt() {
const [store, _actions] = useMegaStore();
const [error, setError] = createSignal<Error>();
const [loading, setLoading] = createSignal(false);
const [_encryptPasswordForm, { Form, Field }] =
createForm<EncryptPasswordForm>({
initialValues: {
existingPassword: "",
password: "",
confirmPassword: ""
},
validate: (values) => {
const errors: Record<string, string> = {};
if (values.password !== values.confirmPassword) {
errors.confirmPassword = "Passwords do not match";
}
return errors;
}
});
const handleFormSubmit = async (f: EncryptPasswordForm) => {
setLoading(true);
try {
await store.mutiny_wallet?.change_password(
f.existingPassword === "" ? undefined : f.existingPassword,
f.password === "" ? undefined : f.password
);
await timeout(1000);
window.location.href = "/";
} catch (e) {
console.error(e);
setError(eify(e));
setLoading(false);
}
};
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<BackLink href="/settings" title="Settings" />
<LargeHeader>
Encrypt your seed words (optional)
</LargeHeader>
<VStack>
<NiceP>
Mutiny is a "hot wallet" so it needs your seed word
to operate, but you can optionally encrypt those
words with a password.
</NiceP>
<NiceP>
That way, if someone gets access to your browser,
they still won't have access to your funds.
</NiceP>
<Form onSubmit={handleFormSubmit}>
<VStack>
<Field name="existingPassword">
{(field, props) => (
<TextField
{...props}
{...field}
type="password"
label="Existing Password (optional)"
placeholder="Existing password"
caption="Leave blank if you haven't set a password yet."
/>
)}
</Field>
<Field name="password">
{(field, props) => (
<TextField
{...props}
{...field}
type="password"
label="Password"
placeholder="Enter a password"
caption="This password will be used to encrypt your seed words. If you forget it, you will need to re-enter your seed words to access your funds. You did write down your seed words, right?"
/>
)}
</Field>
<Field name="confirmPassword">
{(field, props) => (
<TextField
{...props}
{...field}
type="password"
label="Confirm Password"
placeholder="Enter the same password"
/>
)}
</Field>
<Show when={error()}>
<InfoBox accent="red">
{error()?.message}
</InfoBox>
</Show>
<div />
<Button intent="blue" loading={loading()}>
Encrypt
</Button>
</VStack>
</Form>
<ButtonLink href="/settings" intent="green">
Skip
</ButtonLink>
</VStack>
</DefaultMain>
<NavBar activeTab="settings" />
</SafeArea>
</MutinyWalletGuard>
);
}

View File

@@ -2,7 +2,6 @@ import {
Button,
DefaultMain,
LargeHeader,
MutinyWalletGuard,
NiceP,
SafeArea,
VStack
@@ -235,30 +234,28 @@ function TwelveWordsEntry() {
export default function RestorePage() {
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<BackLink title="Settings" href="/settings" />
<LargeHeader>Restore</LargeHeader>
<VStack>
<NiceP>
You can restore an existing Mutiny Wallet from your
12 word seed phrase. This will replace your existing
wallet, so make sure you know what you're doing!
</NiceP>
<NiceP>
<strong class="font-bold text-m-red">
Beta warning:
</strong>{" "}
you can currently only restore on-chain funds.
Lightning backup restore is coming soon.
</NiceP>
<TwelveWordsEntry />
</VStack>
</DefaultMain>
<NavBar activeTab="settings" />
</SafeArea>
</MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<BackLink title="Settings" href="/settings" />
<LargeHeader>Restore</LargeHeader>
<VStack>
<NiceP>
You can restore an existing Mutiny Wallet from your 12
word seed phrase. This will replace your existing
wallet, so make sure you know what you're doing!
</NiceP>
<NiceP>
<strong class="font-bold text-m-red">
Beta warning:
</strong>{" "}
you can currently only restore on-chain funds. Lightning
backup restore is coming soon.
</NiceP>
<TwelveWordsEntry />
</VStack>
</DefaultMain>
<NavBar activeTab="settings" />
</SafeArea>
);
}

View File

@@ -10,6 +10,7 @@ import NavBar from "~/components/NavBar";
import { A } from "solid-start";
import { For, Show } from "solid-js";
import forward from "~/assets/icons/forward.svg";
import { useMegaStore } from "~/state/megaStore";
function SettingsLinkList(props: {
header: string;
@@ -18,6 +19,7 @@ function SettingsLinkList(props: {
text: string;
caption?: string;
accent?: "red" | "green";
disabled?: boolean;
}[];
}) {
return (
@@ -26,7 +28,11 @@ function SettingsLinkList(props: {
{(link) => (
<A
href={link.href}
class="no-underline flex w-full flex-col gap-1 py-2 hover:bg-m-grey-750 active:bg-m-grey-900 px-4 "
class="no-underline flex w-full flex-col gap-1 py-2 hover:bg-m-grey-750 active:bg-m-grey-900 px-4"
classList={{
"opacity-50 cursor pointer-events-none grayscale":
link.disabled
}}
>
<div class="flex justify-between">
<span
@@ -52,6 +58,8 @@ function SettingsLinkList(props: {
}
export default function Settings() {
const [state, _actions] = useMegaStore();
return (
<SafeArea>
<DefaultMain>
@@ -84,6 +92,14 @@ export default function Settings() {
text: "Restore",
accent: "red"
},
{
href: "/settings/encrypt",
text: "Change Password",
disabled: !state.has_backed_up,
caption: !state.has_backed_up
? "Backup first to unlock encryption"
: undefined
},
{
href: "/settings/servers",
text: "Servers",

View File

@@ -47,10 +47,14 @@ export type MegaStore = [
existing_tab_detected: boolean;
subscription_timestamp?: number;
readonly mutiny_plus: boolean;
needs_password: boolean;
},
{
fetchUserStatus(): Promise<UserStatus>;
setupMutinyWallet(settings?: MutinyWalletSettingStrings): Promise<void>;
setupMutinyWallet(
settings?: MutinyWalletSettingStrings,
password?: string
): Promise<void>;
deleteMutinyWallet(): Promise<void>;
setWaitlistId(waitlist_id: string): void;
setScanResult(scan_result: ParsedParams | undefined): void;
@@ -95,7 +99,8 @@ export const Provider: ParentComponent = (props) => {
if (state.subscription_timestamp < Math.ceil(Date.now() / 1000))
return false;
else return true;
}
},
needs_password: false
});
const actions = {
@@ -161,17 +166,24 @@ export const Provider: ParentComponent = (props) => {
}
},
async setupMutinyWallet(
settings?: MutinyWalletSettingStrings
settings?: MutinyWalletSettingStrings,
password?: string
): Promise<void> {
try {
// If we're already in an error state there should be no reason to continue
if (state.setup_error) {
throw state.setup_error;
}
setState({ wallet_loading: true });
// This is where all the real setup happens
const mutinyWallet = await setupMutinyWallet(settings);
const mutinyWallet = await setupMutinyWallet(
settings,
password
);
// If we get this far then we don't need the password anymore
setState({ needs_password: false });
// Get balance optimistically
const balance = await mutinyWallet.get_balance();
@@ -210,7 +222,11 @@ export const Provider: ParentComponent = (props) => {
});
} catch (e) {
console.error(e);
setState({ setup_error: eify(e) });
if (eify(e).message === "Incorrect password entered.") {
setState({ needs_password: true });
} else {
setState({ setup_error: eify(e) });
}
}
},
async deleteMutinyWallet(): Promise<void> {
@@ -315,13 +331,10 @@ export const Provider: ParentComponent = (props) => {
});
} else {
console.log("running setup node manager...");
actions
.setupMutinyWallet()
.then(() => console.log("node manager setup done"))
.catch((e) => {
console.error(e);
setState({ setup_error: eify(e) });
});
.then(() => console.log("node manager setup done"));
// Setup an event listener to stop the mutiny wallet when the page unloads
window.onunload = async (_e) => {

View File

@@ -57,7 +57,9 @@ export default defineConfig({
"nostr-tools",
"class-variance-authority",
"@kobalte/core",
"@solid-primitives/upload"
"@solid-primitives/upload",
"i18next",
"i18next-browser-languagedetector"
],
// This is necessary because otherwise `vite dev` can't find the wasm
exclude: ["@mutinywallet/mutiny-wasm", "@mutinywallet/waila-wasm"]