diff --git a/.vscode/settings.json b/.vscode/settings.json index 900afa0..bccd43e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,7 @@ "fontello": "src/fontello", }, "cSpell.words": [ + "KRISTWALLET", "Lngs", "Transpiler", "Unfocus", @@ -14,6 +15,7 @@ "apos", "arraybuffer", "borderless", + "devtools", "esnext", "firstseen", "focusable", diff --git a/package-lock.json b/package-lock.json index 5ed684b..64444cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11756,6 +11756,12 @@ "symbol-observable": "^1.2.0" } }, + "redux-devtools-extension": { + "version": "2.13.8", + "resolved": "https://registry.npmjs.org/redux-devtools-extension/-/redux-devtools-extension-2.13.8.tgz", + "integrity": "sha512-8qlpooP2QqPtZHQZRhx3x3OP5skEV1py/zUdMY28WNAocbafxdG2tRD1MWE7sp8obGMNYuLWanhhQ7EQvT1FBg==", + "dev": true + }, "regenerate": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.1.tgz", diff --git a/package.json b/package.json index 9943a0b..1eeee27 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "node-sass": "^4.14.1", "patch-package": "^6.2.2", "prettier": "^2.1.1", + "redux-devtools-extension": "^2.13.8", "typescript": "^4.0.3" }, "stylelint": { diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index 647fedc..b4b110b 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -75,8 +75,7 @@ }, "myWallets": { - "title": "{{count}} Wallet", - "title_plural": "{{count}} Wallets", + "title": "Wallets", "manageBackups": "Sicherungen verwalten", "createWallet": "Wallet erstellen", "addExistingWallet": "Bestehendes Wallet hinzufügen", @@ -87,7 +86,6 @@ "columnBalance": "Saldo", "columnNames": "Namen", "columnCategory": "Kategorie", - "columnFirstSeen": "Erstmals erschienen", - "titleLoading": "Wallets werden geladen..." + "columnFirstSeen": "Erstmals erschienen" } } diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 42c354c..5be931a 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -59,15 +59,14 @@ "errorPasswordRequired": "Password is required.", "errorPasswordUnset": "Master password has not been set up.", "errorPasswordIncorrect": "Incorrect password.", + "errorStorageCorrupt": "Wallet storage is corrupted.", "errorUnknown": "Unknown error.", "helpWalletStorageTitle": "Help: Wallet storage", "helpWalletStorage": "When you add a wallet to KristWeb, the private key for the wallet is saved to your browser's local storage and encrypted with your master password.\nEvery wallet you save is encrypted using the same master password, and you will need to enter it every time you open KristWeb. Your actual Krist wallet is not modified in any way.\nWhen browsing KristWeb as a guest, you do not need to enter a master password, but it also means that you will not be able to add or use any wallets. You will still be able to explore the Krist network." }, "myWallets": { - "title": "{{count}} wallet", - "title_plural": "{{count}} wallets", - "titleLoading": "Loading wallets...", + "title": "Wallets", "manageBackups": "Manage backups", "createWallet": "Create wallet", "addExistingWallet": "Add existing wallet", @@ -84,6 +83,15 @@ "firstSeen": "First seen {{date}}" }, + "myTransactions": { + "title": "Transactions", + "searchPlaceholder": "Search transactions...", + "columnFrom": "From", + "columnTo": "To", + "columnValue": "Value", + "columnTime": "Time" + }, + "credits": { "madeBy": "Made by <1>{{authorName}}", "supportersTitle": "Supporters", diff --git a/src/app/App.tsx b/src/app/App.tsx index 8f2674a..09695ac 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -8,9 +8,14 @@ import { createStore } from "redux"; import { Provider } from "react-redux"; +import { devToolsEnhancer } from 'redux-devtools-extension'; import rootReducer from "@/src/store/reducers/RootReducer"; -export const store = createStore(rootReducer); +export const store = createStore( + rootReducer, + undefined, + devToolsEnhancer({}) +); export type AppDispatch = typeof store.dispatch; /*import packageJson from "@/package.json"; diff --git a/src/app/WalletManager.tsx b/src/app/WalletManager.tsx deleted file mode 100644 index bf32275..0000000 --- a/src/app/WalletManager.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { toHex } from "@utils"; -import { aesGcmEncrypt, aesGcmDecrypt } from "@utils/crypto"; - -import { AppDispatch } from "./App"; -import * as actions from "@actions/WalletManagerActions"; - -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("Incorrect password."); - } catch (e) { - // OperationError usually means decryption failure - if (e.name === "OperationError") throw new Error("Incorrect password."); - else throw e; - } - - // 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/layouts/dialogs/MasterPasswordDialog.tsx b/src/layouts/dialogs/MasterPasswordDialog.tsx index f41a225..8c1d1d0 100644 --- a/src/layouts/dialogs/MasterPasswordDialog.tsx +++ b/src/layouts/dialogs/MasterPasswordDialog.tsx @@ -13,7 +13,7 @@ import { Dispatch } from "redux"; import { connect, ConnectedProps } from "react-redux"; import { RootState } from "@store"; -import { browseAsGuest, login, setMasterPassword } from "@app/WalletManager"; +import { browseAsGuest, login, setMasterPassword } from "@/src/wallets/WalletManager"; interface OwnProps { isLoggedIn: boolean; diff --git a/src/layouts/my-transactions/MyTransactionsMobileItem.tsx b/src/layouts/my-transactions/MyTransactionsMobileItem.tsx new file mode 100644 index 0000000..24e4ff3 --- /dev/null +++ b/src/layouts/my-transactions/MyTransactionsMobileItem.tsx @@ -0,0 +1,14 @@ +import React from "react"; + +// TODO: temporary +import { Transaction } from "./MyTransactionsPage"; + +interface Props { + item: Transaction +}; + +export const MyTransactionsMobileItem: React.FC = ({ item }: Props) => { + return <> + {/* TODO */} + +} diff --git a/src/layouts/my-transactions/MyTransactionsPage.tsx b/src/layouts/my-transactions/MyTransactionsPage.tsx new file mode 100644 index 0000000..8ef929c --- /dev/null +++ b/src/layouts/my-transactions/MyTransactionsPage.tsx @@ -0,0 +1,61 @@ +import React, { Component, ReactNode } from "react"; + +import { withTranslation, WithTranslation } from "react-i18next"; + +import { ColumnKey, ColumnSpec, QueryStateBase } from "@components/list-view/DataProvider"; +import { formatKristValue, formatDateTime } from "@components/list-view/Formatters"; +import { ListView } from "@components/list-view/ListView"; + +import { SearchTextbox } from "@components/list-view/SearchTextbox"; +import { DateString } from "@krist/types/KristTypes"; + +import { MyTransactionsMobileItem } from "./MyTransactionsMobileItem"; + +// TODO: Temporary +export interface Transaction { + id: number, + from?: string, + to?: string, + value: number, + time: DateString, + name?: string, + metadata?: string +} + +const COLUMNS = new Map, ColumnSpec>() + .set("from", { nameKey: "myTransactions.columnFrom" }) + .set("to", { nameKey: "myTransactions.columnTo" }) + .set("value", { + nameKey: "myTransactions.columnValue", + formatValue: formatKristValue("value") + }) + .set("time", { + nameKey: "myTransactions.columnTime", + formatValue: formatDateTime("time") + }); + +class MyTransactionsPageComponent extends Component { + render(): ReactNode { + const { t } = this.props; + + return + title={t("myTransactions.title")} + filters={<> + {/* Search filter textbox */} + + } + columns={COLUMNS} + renderMobileItem={(item: Transaction) => } + dataProvider={async (query: QueryStateBase) => { + // Provide the data to the list view + // TODO: temporary + return { + total: 30, + data: [] + }; + }} + />; + } +} + +export const MyTransactionsPage = withTranslation()(MyTransactionsPageComponent); diff --git a/src/layouts/my-wallets/MyWalletsMobileItem.tsx b/src/layouts/my-wallets/MyWalletsMobileItem.tsx index 2f0dd0b..a26cd5b 100644 --- a/src/layouts/my-wallets/MyWalletsMobileItem.tsx +++ b/src/layouts/my-wallets/MyWalletsMobileItem.tsx @@ -1,11 +1,10 @@ -import React, { ReactNode } from "react"; +import React from "react"; import { useTranslation } from "react-i18next"; import { KristValue } from "@components/krist-value/KristValue"; -// TODO: temporary -import { Wallet } from "./MyWalletsPage"; +import { Wallet } from "@/src/wallets/Wallet"; interface Props { item: Wallet diff --git a/src/layouts/my-wallets/MyWalletsPage.tsx b/src/layouts/my-wallets/MyWalletsPage.tsx index 1933c3d..51c5aea 100644 --- a/src/layouts/my-wallets/MyWalletsPage.tsx +++ b/src/layouts/my-wallets/MyWalletsPage.tsx @@ -15,17 +15,10 @@ import { MyWalletsMobileItem } from "./MyWalletsMobileItem"; -import { sleep } from "@utils"; +import { Wallet } from "@/src/wallets/Wallet"; -// TODO: Temporary -export interface Wallet { - label?: string; - address: string; - balance: number; - names: number; - category?: string; - firstSeen?: DateString; -} +import { sleep } from "@utils"; +import { KristWalletFormat } from "@/src/wallets/formats/WalletFormat"; const WALLET_COLUMNS = new Map, ColumnSpec>() .set("label", { nameKey: "myWallets.columnLabel" }) @@ -49,7 +42,7 @@ const { t } = this.props; return - title="23 wallets" + title={t("myWallets.title")} page={1} pages={3} actions={<> @@ -66,7 +59,7 @@ } filters={<> {/* Search filter textbox */} - + {/* Category selection box */} ) => { // Provide the data to the list view // TODO: temporary - await sleep((Math.random() * 500) + 250); + await sleep((Math.random() * 500) + 10000); return { total: 30, data: [ { + id: "9b68f712-8005-4711-8628-3a97d17b5d5d", + password: "", + format: "kristwallet", label: "Shop Wallet", address: "kreichdyes", balance: 15364, @@ -94,6 +90,9 @@ firstSeen: new Date().toISOString() }, { + id: "d0de949f-3270-4bcc-b29a-2cd9e42cec58", + password: "", + format: "kristwallet", label: "Main Wallet", address: "khugepoopy", balance: 1024, @@ -101,6 +100,9 @@ firstSeen: new Date().toISOString() }, { + id: "2769c209-c323-4850-a79d-774c284b6417", + password: "", + format: "kristwallet", label: "Old Wallet", address: "kre3w0i79j", balance: 0, @@ -108,6 +110,9 @@ firstSeen: new Date().toISOString() }, { + id: "0886b802-e871-40f1-b2fc-c39fa295f5c8", + password: "", + format: "kristwallet", address: "kunlabeled", balance: 0, names: 0 diff --git a/src/store/actions/WalletsActions.ts b/src/store/actions/WalletsActions.ts new file mode 100644 index 0000000..4bf14f7 --- /dev/null +++ b/src/store/actions/WalletsActions.ts @@ -0,0 +1,10 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { createAction } from "typesafe-actions"; + +import * as constants from "../constants"; + +import { WalletMap } from "@reducers/WalletsReducer"; + +export interface LoadWalletsPayload { wallets: WalletMap }; +export const loadWallets = createAction(constants.LOAD_WALLETS, + (wallets: WalletMap): LoadWalletsPayload => ({ wallets }))(); diff --git a/src/store/actions/index.ts b/src/store/actions/index.ts index c391f58..515a4e8 100644 --- a/src/store/actions/index.ts +++ b/src/store/actions/index.ts @@ -1,5 +1,7 @@ import * as walletManagerActions from "./WalletManagerActions"; +import * as walletsActions from "./WalletsActions"; export default { - walletManager: walletManagerActions + walletManager: walletManagerActions, + wallets: walletsActions }; diff --git a/src/store/constants.ts b/src/store/constants.ts index 1a6a321..94331e2 100644 --- a/src/store/constants.ts +++ b/src/store/constants.ts @@ -5,3 +5,13 @@ export const OPEN_LOGIN = "OPEN_LOGIN"; export const LOGIN = "LOGIN"; export const SET_MASTER_PASSWORD = "SET_MASTER_PASSWORD"; + +// ----------------------------------------------------------------------------- +// Wallets +// ----------------------------------------------------------------------------- +export const LOAD_WALLETS = "LOAD_WALLETS"; +export const ADD_WALLET = "ADD_WALLET"; +export const REMOVE_WALLET = "REMOVE_WALLET"; +export const RENAME_WALLET = "RENAME_WALLET"; +export const SYNC_WALLET = "SYNC_WALLET"; +export const UPDATE_WALLET_BALANCE = "UPDATE_WALLET_BALANCE" diff --git a/src/store/reducers/RootReducer.ts b/src/store/reducers/RootReducer.ts index 4d2049b..0725993 100644 --- a/src/store/reducers/RootReducer.ts +++ b/src/store/reducers/RootReducer.ts @@ -1,6 +1,9 @@ import { combineReducers } from "redux"; + import { WalletManagerReducer } from "@reducers/WalletManagerReducer"; +import { WalletsReducer } from "./WalletsReducer"; export default combineReducers({ - walletManager: WalletManagerReducer + walletManager: WalletManagerReducer, + wallets: WalletsReducer }); diff --git a/src/store/reducers/WalletsReducer.ts b/src/store/reducers/WalletsReducer.ts new file mode 100644 index 0000000..182b28f --- /dev/null +++ b/src/store/reducers/WalletsReducer.ts @@ -0,0 +1,21 @@ +import { loadWallets } from "@actions/WalletsActions"; +import { createReducer, ActionType } from "typesafe-actions"; + +import { Wallet } from "../../wallets/Wallet"; + +export type WalletMap = { [key: string]: Wallet }; +export interface State { + readonly wallets: WalletMap; +} + +const initialState: State = { + wallets: {} +}; + +export const WalletsReducer = createReducer(initialState) + .handleAction(loadWallets, (state: State, action: ActionType) => ({ + wallets: { + ...state.wallets, + ...action.payload.wallets + } + })); diff --git a/src/wallets/Wallet.ts b/src/wallets/Wallet.ts new file mode 100644 index 0000000..466c92a --- /dev/null +++ b/src/wallets/Wallet.ts @@ -0,0 +1,100 @@ +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; +} + +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; +} + +declare global { + interface Window { + encryptWallet: typeof encryptWallet + } +} +window.encryptWallet = encryptWallet; + +/** 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)); +} diff --git a/src/wallets/WalletManager.ts b/src/wallets/WalletManager.ts new file mode 100644 index 0000000..b897ef7 --- /dev/null +++ b/src/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 "../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 new file mode 100644 index 0000000..b2c700d --- /dev/null +++ b/src/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 +};