diff --git a/.eslintrc.json b/.eslintrc.json index c435bea..76b5de0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -50,7 +50,6 @@ "eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier/@typescript-eslint", - "plugin:react/recommended", - "plugin:react-hooks/recommended" + "plugin:react/recommended" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index bccd43e..30fbe00 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ "cSpell.words": [ "KRISTWALLET", "Lngs", + "Syncable", "Transpiler", "Unfocus", "Unmount", @@ -16,6 +17,7 @@ "arraybuffer", "borderless", "devtools", + "dont", "esnext", "firstseen", "focusable", diff --git a/src/krist/AddressAlgo.ts b/src/krist/AddressAlgo.ts new file mode 100644 index 0000000..942b655 --- /dev/null +++ b/src/krist/AddressAlgo.ts @@ -0,0 +1,31 @@ +import { sha256, doubleSHA256 } from "@utils/crypto"; + +const hexToBase36 = (input: number): string => { + const byte = 48 + Math.floor(input / 7); + return String.fromCharCode(byte + 39 > 122 ? 101 : byte > 57 ? byte + 39 : byte); +} + +export const makeV2Address = async (key: string): Promise => { + const chars = ["", "", "", "", "", "", "", "", ""]; + let chain = "k"; // TODO: custom prefixes + let hash = await doubleSHA256(key); + + for (let i = 0; i <= 8; i++) { + chars[i] = hash.substring(0, 2); + hash = await doubleSHA256(hash); + } + + for (let i = 0; i <= 8;) { + const index = parseInt(hash.substring(2 * i, 2 + (2 * i)), 16) % 9; + + if (chars[index] === "") { + hash = await sha256(hash); + } else { + chain += hexToBase36(parseInt(chars[index], 16)); + chars[index] = ""; + i++; + } + } + + return chain; +} diff --git a/src/krist/wallets/Wallet.ts b/src/krist/wallets/Wallet.ts new file mode 100644 index 0000000..533ab4b --- /dev/null +++ b/src/krist/wallets/Wallet.ts @@ -0,0 +1,109 @@ +import { DateString } from "@krist/types/KristTypes"; +import { WalletFormatName } from "./formats/WalletFormat"; + +import { AESEncryptedString, aesGcmDecrypt, aesGcmEncrypt } from "@utils/crypto"; + +import { AppDispatch } from "@app/App"; +import * as actions from "@actions/WalletsActions"; +import { WalletMap } from "@reducers/WalletsReducer"; + +import Debug from "debug"; +const debug = Debug("kristweb:wallet"); + +export interface Wallet { + // UUID for this wallet + id: string; + + // User assignable data + label?: string; + category?: string; + + // Login info + password: string; + username?: string; + format: WalletFormatName; + + // Fetched from API + address: string; + balance: number; + names: number; + firstSeen?: DateString; +} + +/** Properties of Wallet that are allowed to be updated. */ +export type WalletUpdatableKeys = "label" | "category" | "password" | "username" | "format" | "address"; +export type WalletUpdatable = Pick; + +/** Properties of Wallet that are allowed to be synced. */ +export type WalletSyncableKeys = "balance" | "names" | "firstSeen"; +export type WalletSyncable = Pick; + +export async function decryptWallet(id: string, data: AESEncryptedString, masterPassword: string): Promise { + try { + // Attempt to decrypt and deserialize the wallet data + const dec = await aesGcmDecrypt(data, masterPassword); + const wallet: Wallet = JSON.parse(dec); + + // Validate the wallet data actually makes sense + if (!wallet || !wallet.id || wallet.id !== id) + throw new Error("masterPassword.walletStorageCorrupt"); + + return wallet; + } catch (e) { + if (e.name === "OperationError") { + // OperationError usually means decryption failure + console.error(e); + throw new Error("masterPassword.errorPasswordIncorrect"); + } else if (e.name === "SyntaxError") { + // SyntaxError means the JSON was invalid + console.error(e); + throw new Error("masterPassword.errorStorageCorrupt"); + } else { + // Unknown error + throw e; + } + } +} + +export async function encryptWallet(wallet: Wallet, masterPassword: string): Promise { + const data = JSON.stringify(wallet); + const enc = await aesGcmEncrypt(data, masterPassword); + return enc; +} + +/** Get the local storage key for a given wallet. */ +export function getWalletKey(wallet: Wallet): string { + return `wallet2-${wallet.id}`; +} + +/** Extract a wallet ID from a local storage key. */ +const walletKeyRegex = /^wallet2-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/; +export function extractWalletKey(key: string): [string, string] | undefined { + const [,id] = walletKeyRegex.exec(key) || []; + return id ? [key, id] : undefined; +} + +/** Loads all available wallets from local storage and dispatches them to the + * Redux store. */ +export async function loadWallets(dispatch: AppDispatch, masterPassword: string) { + // Find all `wallet2` keys from local storage. + const keysToLoad = Object.keys(localStorage) + .map(extractWalletKey) + .filter(k => k !== undefined); + + const wallets = await Promise.all(keysToLoad + .map(([key, id]) => decryptWallet(id, localStorage.getItem(key)!, masterPassword))); + + // Convert to map with wallet IDs + const walletMap: WalletMap = wallets.reduce((obj, w) => ({ ...obj, [w.id]: w }), {}); + + dispatch(actions.loadWallets(walletMap)); +} + +// TODO: temporary exposure of methods for testing +declare global { + interface Window { + encryptWallet: typeof encryptWallet + } +} +window.encryptWallet = encryptWallet; diff --git a/src/krist/wallets/WalletManager.ts b/src/krist/wallets/WalletManager.ts new file mode 100644 index 0000000..d3e0356 --- /dev/null +++ b/src/krist/wallets/WalletManager.ts @@ -0,0 +1,57 @@ +import { toHex } from "@utils"; +import { aesGcmEncrypt, aesGcmDecrypt } from "@utils/crypto"; + +import { AppDispatch } from "@app/App"; +import * as actions from "@actions/WalletManagerActions"; + +import { loadWallets } from "./Wallet"; + +export function browseAsGuest(dispatch: AppDispatch): void { + dispatch(actions.browseAsGuest()); +} + +/** Verifies that the given password is correct, and dispatches the login + * action to the Redux store. */ +export async function login(dispatch: AppDispatch, salt: string | undefined, tester: string | undefined, password: string): Promise { + if (!password) throw new Error("masterPassword.errorPasswordRequired"); + if (!salt || !tester) throw new Error("masterPassword.errorPasswordUnset"); + + try { + // Attempt to decrypt the tester with the given password + const testerDec = await aesGcmDecrypt(tester, password); + + // Verify that the decrypted tester is equal to the salt, if not, the + // provided master password is incorrect. + if (testerDec !== salt) throw new Error("masterPassword.errorPasswordIncorrect"); + } catch (e) { + // OperationError usually means decryption failure + if (e.name === "OperationError") throw new Error("masterPassword.errorPasswordIncorrect"); + else throw e; + } + + // Load the wallets, dispatching the wallets to the Redux store + loadWallets(dispatch, password); + + // Dispatch the login state changes to the Redux store + dispatch(actions.login(password)); +} + +/** Generates a salt and tester, sets the master password, and dispatches the + * action to the Redux store. */ +export async function setMasterPassword(dispatch: AppDispatch, password: string): Promise { + if (!password) throw new Error("Password is required."); + + // Generate the salt (to be encrypted with the master password) + const salt = window.crypto.getRandomValues(new Uint8Array(32)); + const saltHex = toHex(salt); + + // Generate the encryption tester + const tester = await aesGcmEncrypt(saltHex, password); + + // Store them in local storage + localStorage.setItem("salt", saltHex); + localStorage.setItem("tester", tester); + + // Dispatch the login state changes to the Redux store + dispatch(actions.setMasterPassword(saltHex, tester, password)); +} diff --git a/src/krist/wallets/formats/WalletFormat.ts b/src/krist/wallets/formats/WalletFormat.ts new file mode 100644 index 0000000..b2c700d --- /dev/null +++ b/src/krist/wallets/formats/WalletFormat.ts @@ -0,0 +1,17 @@ +import { sha256 } from "@utils/crypto"; + +export interface WalletFormat { + (password: string, username?: string): Promise; +} + +export const KristWalletFormat: WalletFormat + = async password => await sha256("KRISTWALLET" + password) + "-000"; + +export const APIFormat: WalletFormat + = async password => password; + +export type WalletFormatName = "kristwallet" | "api"; +export const WalletFormatMap: Record = { + "kristwallet": KristWalletFormat, + "api": APIFormat +}; diff --git a/src/layouts/dialogs/MasterPasswordDialog.tsx b/src/layouts/dialogs/MasterPasswordDialog.tsx index 8c1d1d0..0ad389c 100644 --- a/src/layouts/dialogs/MasterPasswordDialog.tsx +++ b/src/layouts/dialogs/MasterPasswordDialog.tsx @@ -2,18 +2,18 @@ import { withTranslation, WithTranslation, Trans } from "react-i18next"; -import Modal from "react-bootstrap/Modal"; import Button from "react-bootstrap/Button"; import Form from "react-bootstrap/Form"; -import { Formik, FormikHelpers } from "formik"; +import { ModalDialog } from "./ModalDialog"; +import { FormikHelpers } from "formik"; import { HelpWalletStorageLink } from "./HelpWalletStorageDialog"; import { Dispatch } from "redux"; import { connect, ConnectedProps } from "react-redux"; import { RootState } from "@store"; -import { browseAsGuest, login, setMasterPassword } from "@/src/wallets/WalletManager"; +import { browseAsGuest, login, setMasterPassword } from "@krist/wallets/WalletManager"; interface OwnProps { isLoggedIn: boolean; @@ -91,72 +91,67 @@ ; return ( - <> + {/* Left side, "Browse as guest" button */} + + + {/* Right side */} + {hasMasterPassword + ? <> {/* They have a master password, show login */} + + + + : ( + + ) + } + } > - - {({ handleSubmit, handleChange, values, errors, isSubmitting }) => ( -
- - {t("masterPassword.dialogTitle")} - - - {/* Embed the body text, which depends on whether or not this is - the first time setting up a master password. */} - {body} + {({ handleChange, values, errors }) => <> + {/* Embed the body text, which depends on whether or not this is the + first time setting up a master password. */} + {body} - {/* Provide a username field for browser autofill */} - - - {/* Left side, "Browse as guest" button */} - - - {/* Right side */} - {hasMasterPassword - ? <> {/* They have a master password, show login */} - - - - : ( - - ) - } - -
- )} -
-
+ {/* Password input */} + + {errors.password} + } + ); } } diff --git a/src/layouts/dialogs/ModalDialog.tsx b/src/layouts/dialogs/ModalDialog.tsx index fa0596a..cceabb4 100644 --- a/src/layouts/dialogs/ModalDialog.tsx +++ b/src/layouts/dialogs/ModalDialog.tsx @@ -1,11 +1,13 @@ -import React, { PropsWithChildren, ReactNode } from "react"; +import React, { Component, ReactNode } from "react"; import Modal from "react-bootstrap/Modal"; import { CloseButton } from "./utils/CloseButton"; -import { noop } from "@utils"; +import { Form, Formik, FormikHelpers, FormikProps, FormikValues } from "formik"; -interface Props { +import { isFunction, noop } from "@utils"; + +interface Props { show: boolean; title: string; @@ -14,35 +16,71 @@ hasCloseButton?: boolean; hasFooterCloseButton?: boolean; - buttons?: ReactNode; + buttons?: ((props: FormikProps) => React.ReactNode) | React.ReactNode; + + initialValues?: V; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onSubmit?: (values: V, formikHelpers: FormikHelpers) => void | Promise; + children?: ((props: FormikProps) => React.ReactNode) | React.ReactNode; } -export const ModalDialog: React.FC = (props: PropsWithChildren) => { - if ((props.hasCloseButton || props.hasFooterCloseButton) && !props.handleClose) - throw new Error("ModalDialog has close button but no close handler"); +export class ModalDialog extends Component> { + render(): ReactNode { + const { + hasCloseButton, hasFooterCloseButton, handleClose, // Close button + show, title, // Modal state and title + children, buttons, // Actual contents + initialValues, onSubmit // Form nonsense + } = this.props; - return ( - /* TODO: Animation is disabled for now, because react-bootstrap (or more - specifically, react-transition-group) has an incompatibility with - strict mode. */ - - - {props.title} + if ((hasCloseButton || hasFooterCloseButton) && !handleClose) + throw new Error("FormDialog has close button but no close handler"); + + // The contents of the modal (header, body, footer), which may or may not + // be wrapped in a form. + const modalContents = (formikBag?: FormikProps) => <> + + {title} {/* Custom modal body */} - {props.children} + {isFunction(children) && formikBag + ? (children as (bag: FormikProps) => React.ReactNode)(formikBag) + : children} {/* Display the footer close button if we were asked to */} - {props.hasFooterCloseButton && } + {hasFooterCloseButton && } {/* Display the custom buttons if provided */} - {props.buttons} + {isFunction(buttons) && formikBag + ? (buttons as (bag: FormikProps) => React.ReactNode)(formikBag) + : buttons} - - ); + ; + + return ( + /* TODO: Animation is disabled for now, because react-bootstrap (or more + specifically, react-transition-group) has an incompatibility with + strict mode. */ + + {initialValues && onSubmit + /* If this is a form, wrap the contents in Formik: */ + ? + {(formikBag) => +
+ {modalContents(formikBag)} +
+ } +
+ /* Otherwise, render the contents directly: */ + : modalContents()} +
+ ); + } }; diff --git a/src/layouts/my-wallets/MyWalletsMobileItem.tsx b/src/layouts/my-wallets/MyWalletsMobileItem.tsx index a26cd5b..822ce83 100644 --- a/src/layouts/my-wallets/MyWalletsMobileItem.tsx +++ b/src/layouts/my-wallets/MyWalletsMobileItem.tsx @@ -4,7 +4,7 @@ import { KristValue } from "@components/krist-value/KristValue"; -import { Wallet } from "@/src/wallets/Wallet"; +import { Wallet } from "@krist/wallets/Wallet"; interface Props { item: Wallet diff --git a/src/layouts/my-wallets/MyWalletsPage.tsx b/src/layouts/my-wallets/MyWalletsPage.tsx index 51c5aea..fc29aa8 100644 --- a/src/layouts/my-wallets/MyWalletsPage.tsx +++ b/src/layouts/my-wallets/MyWalletsPage.tsx @@ -15,10 +15,10 @@ import { MyWalletsMobileItem } from "./MyWalletsMobileItem"; -import { Wallet } from "@/src/wallets/Wallet"; +import { Wallet } from "@krist/wallets/Wallet"; import { sleep } from "@utils"; -import { KristWalletFormat } from "@/src/wallets/formats/WalletFormat"; +import { KristWalletFormat } from "@krist/wallets/formats/WalletFormat"; const WALLET_COLUMNS = new Map, ColumnSpec>() .set("label", { nameKey: "myWallets.columnLabel" }) diff --git a/src/store/actions/WalletsActions.ts b/src/store/actions/WalletsActions.ts index e816ef4..dd9cc6b 100644 --- a/src/store/actions/WalletsActions.ts +++ b/src/store/actions/WalletsActions.ts @@ -4,7 +4,7 @@ import * as constants from "../constants"; import { WalletMap } from "@reducers/WalletsReducer"; -import { Wallet, WalletSyncable, WalletUpdatable } from "@/src/wallets/Wallet"; +import { Wallet, WalletSyncable, WalletUpdatable } from "@krist/wallets/Wallet"; export interface LoadWalletsPayload { wallets: WalletMap }; export const loadWallets = createAction(constants.LOAD_WALLETS, diff --git a/src/store/reducers/WalletsReducer.ts b/src/store/reducers/WalletsReducer.ts index 73d6934..b0eb763 100644 --- a/src/store/reducers/WalletsReducer.ts +++ b/src/store/reducers/WalletsReducer.ts @@ -1,7 +1,7 @@ import { addWallet, loadWallets, removeWallet, syncWallet, updateWallet } from "@actions/WalletsActions"; import { createReducer, ActionType } from "typesafe-actions"; -import { Wallet } from "../../wallets/Wallet"; +import { Wallet } from "../../krist/wallets/Wallet"; export type WalletMap = { [key: string]: Wallet }; export interface State { diff --git a/src/utils/index.ts b/src/utils/index.ts index f6bc4b7..a079222 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -31,3 +31,7 @@ /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ ) ); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isFunction = (obj: any): obj is (...args: any[]) => any => + typeof obj === "function"; diff --git a/src/wallets/Wallet.ts b/src/wallets/Wallet.ts deleted file mode 100644 index 533ab4b..0000000 --- a/src/wallets/Wallet.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { DateString } from "@krist/types/KristTypes"; -import { WalletFormatName } from "./formats/WalletFormat"; - -import { AESEncryptedString, aesGcmDecrypt, aesGcmEncrypt } from "@utils/crypto"; - -import { AppDispatch } from "@app/App"; -import * as actions from "@actions/WalletsActions"; -import { WalletMap } from "@reducers/WalletsReducer"; - -import Debug from "debug"; -const debug = Debug("kristweb:wallet"); - -export interface Wallet { - // UUID for this wallet - id: string; - - // User assignable data - label?: string; - category?: string; - - // Login info - password: string; - username?: string; - format: WalletFormatName; - - // Fetched from API - address: string; - balance: number; - names: number; - firstSeen?: DateString; -} - -/** Properties of Wallet that are allowed to be updated. */ -export type WalletUpdatableKeys = "label" | "category" | "password" | "username" | "format" | "address"; -export type WalletUpdatable = Pick; - -/** Properties of Wallet that are allowed to be synced. */ -export type WalletSyncableKeys = "balance" | "names" | "firstSeen"; -export type WalletSyncable = Pick; - -export async function decryptWallet(id: string, data: AESEncryptedString, masterPassword: string): Promise { - try { - // Attempt to decrypt and deserialize the wallet data - const dec = await aesGcmDecrypt(data, masterPassword); - const wallet: Wallet = JSON.parse(dec); - - // Validate the wallet data actually makes sense - if (!wallet || !wallet.id || wallet.id !== id) - throw new Error("masterPassword.walletStorageCorrupt"); - - return wallet; - } catch (e) { - if (e.name === "OperationError") { - // OperationError usually means decryption failure - console.error(e); - throw new Error("masterPassword.errorPasswordIncorrect"); - } else if (e.name === "SyntaxError") { - // SyntaxError means the JSON was invalid - console.error(e); - throw new Error("masterPassword.errorStorageCorrupt"); - } else { - // Unknown error - throw e; - } - } -} - -export async function encryptWallet(wallet: Wallet, masterPassword: string): Promise { - const data = JSON.stringify(wallet); - const enc = await aesGcmEncrypt(data, masterPassword); - return enc; -} - -/** Get the local storage key for a given wallet. */ -export function getWalletKey(wallet: Wallet): string { - return `wallet2-${wallet.id}`; -} - -/** Extract a wallet ID from a local storage key. */ -const walletKeyRegex = /^wallet2-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/; -export function extractWalletKey(key: string): [string, string] | undefined { - const [,id] = walletKeyRegex.exec(key) || []; - return id ? [key, id] : undefined; -} - -/** Loads all available wallets from local storage and dispatches them to the - * Redux store. */ -export async function loadWallets(dispatch: AppDispatch, masterPassword: string) { - // Find all `wallet2` keys from local storage. - const keysToLoad = Object.keys(localStorage) - .map(extractWalletKey) - .filter(k => k !== undefined); - - const wallets = await Promise.all(keysToLoad - .map(([key, id]) => decryptWallet(id, localStorage.getItem(key)!, masterPassword))); - - // Convert to map with wallet IDs - const walletMap: WalletMap = wallets.reduce((obj, w) => ({ ...obj, [w.id]: w }), {}); - - dispatch(actions.loadWallets(walletMap)); -} - -// TODO: temporary exposure of methods for testing -declare global { - interface Window { - encryptWallet: typeof encryptWallet - } -} -window.encryptWallet = encryptWallet; diff --git a/src/wallets/WalletManager.ts b/src/wallets/WalletManager.ts deleted file mode 100644 index b897ef7..0000000 --- a/src/wallets/WalletManager.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { toHex } from "@utils"; -import { aesGcmEncrypt, aesGcmDecrypt } from "@utils/crypto"; - -import { AppDispatch } from "@app/App"; -import * as actions from "@actions/WalletManagerActions"; - -import { loadWallets } from "../wallets/Wallet"; - -export function browseAsGuest(dispatch: AppDispatch): void { - dispatch(actions.browseAsGuest()); -} - -/** Verifies that the given password is correct, and dispatches the login - * action to the Redux store. */ -export async function login(dispatch: AppDispatch, salt: string | undefined, tester: string | undefined, password: string): Promise { - if (!password) throw new Error("masterPassword.errorPasswordRequired"); - if (!salt || !tester) throw new Error("masterPassword.errorPasswordUnset"); - - try { - // Attempt to decrypt the tester with the given password - const testerDec = await aesGcmDecrypt(tester, password); - - // Verify that the decrypted tester is equal to the salt, if not, the - // provided master password is incorrect. - if (testerDec !== salt) throw new Error("masterPassword.errorPasswordIncorrect"); - } catch (e) { - // OperationError usually means decryption failure - if (e.name === "OperationError") throw new Error("masterPassword.errorPasswordIncorrect"); - else throw e; - } - - // Load the wallets, dispatching the wallets to the Redux store - loadWallets(dispatch, password); - - // Dispatch the login state changes to the Redux store - dispatch(actions.login(password)); -} - -/** Generates a salt and tester, sets the master password, and dispatches the - * action to the Redux store. */ -export async function setMasterPassword(dispatch: AppDispatch, password: string): Promise { - if (!password) throw new Error("Password is required."); - - // Generate the salt (to be encrypted with the master password) - const salt = window.crypto.getRandomValues(new Uint8Array(32)); - const saltHex = toHex(salt); - - // Generate the encryption tester - const tester = await aesGcmEncrypt(saltHex, password); - - // Store them in local storage - localStorage.setItem("salt", saltHex); - localStorage.setItem("tester", tester); - - // Dispatch the login state changes to the Redux store - dispatch(actions.setMasterPassword(saltHex, tester, password)); -} diff --git a/src/wallets/formats/WalletFormat.ts b/src/wallets/formats/WalletFormat.ts deleted file mode 100644 index b2c700d..0000000 --- a/src/wallets/formats/WalletFormat.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { sha256 } from "@utils/crypto"; - -export interface WalletFormat { - (password: string, username?: string): Promise; -} - -export const KristWalletFormat: WalletFormat - = async password => await sha256("KRISTWALLET" + password) + "-000"; - -export const APIFormat: WalletFormat - = async password => password; - -export type WalletFormatName = "kristwallet" | "api"; -export const WalletFormatMap: Record = { - "kristwallet": KristWalletFormat, - "api": APIFormat -};