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() {
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>
<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
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.
you can currently only restore on-chain funds. Lightning
backup restore is coming soon.
</NiceP>
<TwelveWordsEntry />
</VStack>
</DefaultMain>
<NavBar activeTab="settings" />
</SafeArea>
</MutinyWalletGuard>
);
}

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,8 +222,12 @@ export const Provider: ParentComponent = (props) => {
});
} catch (e) {
console.error(e);
if (eify(e).message === "Incorrect password entered.") {
setState({ needs_password: true });
} else {
setState({ setup_error: eify(e) });
}
}
},
async deleteMutinyWallet(): Promise<void> {
try {
@@ -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"]