diff --git a/src/krist/wallets/functions/decryptWallet.ts b/src/krist/wallets/functions/decryptWallet.ts index 503bad6..aeb3c3e 100644 --- a/src/krist/wallets/functions/decryptWallet.ts +++ b/src/krist/wallets/functions/decryptWallet.ts @@ -3,14 +3,15 @@ // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { aesGcmDecrypt } from "@utils/crypto"; -import { Wallet, WalletAddressMap } from ".."; +import { WalletAddressMap } from ".."; +export interface EncryptedWallet { encPassword: string; encPrivatekey: string } export interface DecryptedWallet { password: string; privatekey: string } /** Decrypts a wallet's password and privatekey. */ export async function decryptWallet( masterPassword: string, - wallet: Wallet + wallet: EncryptedWallet ): Promise { try { const decPassword = await aesGcmDecrypt(wallet.encPassword, masterPassword); diff --git a/src/pages/backup/backupFormats.ts b/src/pages/backup/backupFormats.ts index 763573c..596cbd1 100644 --- a/src/pages/backup/backupFormats.ts +++ b/src/pages/backup/backupFormats.ts @@ -57,6 +57,7 @@ version: 2; wallets: Record; + // TODO: // contacts: Record; } export const isBackupKristWebV2 = (backup: Backup): backup is BackupKristWebV2 => diff --git a/src/pages/backup/backupImport.ts b/src/pages/backup/backupImport.ts index 1f08b70..d9d01d0 100644 --- a/src/pages/backup/backupImport.ts +++ b/src/pages/backup/backupImport.ts @@ -8,9 +8,12 @@ import { aesGcmDecrypt } from "@utils/crypto"; import { decryptCryptoJS } from "@utils/CryptoJS"; -import { Backup, BackupFormatType, isBackupKristWebV1 } from "./backupFormats"; +import { + Backup, BackupFormatType, isBackupKristWebV1, isBackupKristWebV2 +} from "./backupFormats"; import { BackupResults } from "./backupResults"; import { importV1Backup } from "./backupImportV1"; +import { importV2Backup } from "./backupImportV2"; import Debug from "debug"; const debug = Debug("kristweb:backup-import"); @@ -112,6 +115,12 @@ backup, masterPassword, noOverwrite, results ); + } else if (isBackupKristWebV2(backup)) { + await importV2Backup( + existingWallets, appMasterPassword, addressPrefix, + backup, masterPassword, noOverwrite, + results + ); } else { debug("WTF: unsupported backup format %s", backup.type); } diff --git a/src/pages/backup/backupImportUtils.ts b/src/pages/backup/backupImportUtils.ts new file mode 100644 index 0000000..2770ba5 --- /dev/null +++ b/src/pages/backup/backupImportUtils.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import { BackupResults, BackupWalletError, MessageType } from "./backupResults"; + +import { + ADDRESS_LIST_LIMIT, + WALLET_FORMATS, ADVANCED_FORMATS, WalletFormatName, formatNeedsUsername, + WalletMap, Wallet, WalletNew, calculateAddress, editWalletLabel, addWallet +} from "@wallets"; + +import Debug from "debug"; +const debug = Debug("kristweb:backup-import-utils"); + +interface ShorthandsRes { + warn: (message: MessageType) => void; + success: (message: MessageType) => void; + + importWarnings: MessageType[]; + importWarn: (msg: MessageType) => void; +} + +export function getShorthands( + results: BackupResults, + uuid: string, + version: string +): ShorthandsRes { + const warn = results.addWarningMessage.bind(results, "wallets", uuid); + const success = results.addSuccessMessage.bind(results, "wallets", uuid); + + // Warnings to only be added if the wallet was actually added + const importWarnings: MessageType[] = []; + const importWarn = (msg: MessageType) => { + debug("%s wallet %s added import warning:", version, uuid, msg); + + // Prepend the i18n key if it was just a string + importWarnings.push(typeof msg === "string" + ? "import.walletMessages." + msg + : msg); + }; + + return { warn, success, importWarnings, importWarn }; +} + +/** Asserts that `val` is a truthy string */ +export const str = (val: any): val is string => val && typeof val === "string"; + +// ----------------------------------------------------------------------------- +// REQUIRED PROPERTY VALIDATION +// ----------------------------------------------------------------------------- +/** Converts a v1 format name to a v2 format name. */ +const _upgradeFormatName = (name: string): WalletFormatName => + name === "krist" ? "api" : name as WalletFormatName; + +/** Verifies that the wallet's format is valid, upgrade it if necessary, + * and check if it's an advanced format. */ +export function checkFormat( + { importWarn }: ShorthandsRes, + wallet: { format?: string; username?: string } +): { + format: WalletFormatName; + username?: string; +} { + // Check if the wallet format is supported (converting the old format name + // `krist` to `api` if necessary) + const format = _upgradeFormatName(wallet.format || "kristwallet"); + if (!WALLET_FORMATS[format]) throw new BackupWalletError("errorUnknownFormat"); + + // Check if the wallet is using an advanced (unsupported) format + if (ADVANCED_FORMATS.includes(format)) + importWarn({ key: "import.walletMessages.warningAdvancedFormat", args: { format }}); + + // If the wallet format requires a username, check that the username is + // actually present + const { username } = wallet; + if (formatNeedsUsername(format) && !str(username)) + throw new BackupWalletError("errorUsernameMissing"); + + return { format, username }; +} + +// ----------------------------------------------------------------------------- +// OPTIONAL PROPERTY VALIDATION +// ----------------------------------------------------------------------------- +export const isLabelValid = (label?: string): boolean => + str(label) && label.trim().length < 32; +export function checkLabelValid({ importWarn }: ShorthandsRes, label?: string): void { + const labelValid = isLabelValid(label); + if (label && !labelValid) importWarn("warningLabelInvalid"); +} + +export const isCategoryValid = isLabelValid; +export function checkCategoryValid({ importWarn }: ShorthandsRes, category?: string): void { + const categoryValid = isCategoryValid(category); + if (category && !categoryValid) importWarn("warningCategoryInvalid"); +} + +// -------------------------- --------------------------------------------------- +// WALLET IMPORT PREPARATION/VALIDATION +// ----------------------------------------------------------------------------- +interface CheckAddressRes { + address: string; + privatekey: string; + existingWallet?: Wallet; + existingImportWallet?: Wallet; +} + +export async function checkAddress( + addressPrefix: string, + existingWallets: WalletMap, + results: BackupResults, + + oldPrivatekey: string, + privatekeyMismatchErrorKey: + "errorMasterKeyMismatch" | "errorPrivateKeyMismatch", + + format: WalletFormatName | undefined, + password: string, + username?: string +): Promise { + // Calculate the address in advance, to check for existing wallets + const { privatekey, address } = await calculateAddress( + addressPrefix, + format || "kristwallet", + password, + username + ); + + // Check that our calculated private key is actually equal to the stored + // private key. In practice these should never be different. + if (privatekey !== oldPrivatekey) + throw new BackupWalletError(privatekeyMismatchErrorKey); + + // Check if a wallet already exists, either in the Redux store, or our list of + // imported wallets during this backup import + const existingWallet = Object.values(existingWallets) + .find(w => w.address === address); + const existingImportWallet = results.importedWallets + .find(w => w.address === address); + + return { address, privatekey, existingWallet, existingImportWallet }; +} + +// ----------------------------------------------------------------------------- +// WALLET IMPORT +// ----------------------------------------------------------------------------- +export async function finalWalletImport( + existingWallets: WalletMap, + appMasterPassword: string, + addressPrefix: string, + + shorthands: ShorthandsRes, + results: BackupResults, + noOverwrite: boolean, + + existingWallet: Wallet | undefined, + address: string, + password: string, + newWalletData: WalletNew +): Promise { + const { warn, success, importWarnings } = shorthands; + + const { label } = newWalletData; + const labelValid = isLabelValid(label); + + // Handle duplicate wallets + if (existingWallet) { + // The wallet already exists in the Redux store, so determine if we need to + // update its label (only if it was determined to be valid) + if (labelValid && existingWallet.label !== label!.trim()) { + if (noOverwrite) { + // The user didn't want to overwrite the wallet, so skip it + results.skippedWallets++; + return success({ key: "import.walletMessages.successSkippedNoOverwrite", args: { address }}); + } else { + const newLabel = label!.trim(); + debug( + "changing existing wallet %s (%s) label from %s to %s", + existingWallet.id, existingWallet.address, + existingWallet.label, newLabel + ); + + await editWalletLabel(existingWallet, newLabel); + + return success({ key: "import.walletMessages.successUpdated", args: { address, label: newLabel }}); + } + } else { + results.skippedWallets++; + return success({ key: "import.walletMessages.successSkipped", args: { address }}); + } + } + + // No existing wallet to update/skip, so now check if we can add it without + // going over the wallet limit + const currentWalletCount = + Object.keys(existingWallets).length + results.importedWallets.length; + if (currentWalletCount >= ADDRESS_LIST_LIMIT) + throw new BackupWalletError("errorLimitReached"); + + // Now that we're actually importing the wallet, push any warnings that may + // have been generated + importWarnings.forEach(warn); + + debug("adding new wallet %s", address); + const newWallet = await addWallet( + addressPrefix, appMasterPassword, + newWalletData, password, + true + ); + debug("new wallet %s (%s)", newWallet.id, newWallet.address); + + // Add it to the results + results.newWallets++; + results.importedWallets.push(newWallet); // To keep track of limits + return success("import.walletMessages.success"); +} diff --git a/src/pages/backup/backupImportV1.ts b/src/pages/backup/backupImportV1.ts index 13534fa..783d57b 100644 --- a/src/pages/backup/backupImportV1.ts +++ b/src/pages/backup/backupImportV1.ts @@ -2,14 +2,14 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { BackupKristWebV1, KristWebV1Wallet } from "./backupFormats"; -import { BackupWalletError, BackupResults, MessageType } from "./backupResults"; +import { BackupWalletError, BackupResults } from "./backupResults"; import { backupDecryptValue } from "./backupImport"; - import { - ADDRESS_LIST_LIMIT, - WALLET_FORMATS, ADVANCED_FORMATS, WalletFormatName, formatNeedsUsername, - WalletMap, WalletNew, calculateAddress, editWalletLabel, addWallet -} from "@wallets"; + getShorthands, str, checkFormat, checkAddress, checkLabelValid, + finalWalletImport +} from "./backupImportUtils"; + +import { WalletMap } from "@wallets"; import { isPlainObject, memoize } from "lodash-es"; import to from "await-to-js"; @@ -20,10 +20,6 @@ /** Strips http/https from a sync node to properly compare it. */ const _cleanSyncNode = memoize((node: string) => node.replace(/^https?:/, "")); -/** Converts a v1 format name to a v2 format name. */ -const _upgradeFormatName = (name: string): WalletFormatName => - name === "krist" ? "api" : name as WalletFormatName; - /** Imports a KristWeb v1 backup. */ export async function importV1Backup( // Things regarding the app's existing state @@ -85,24 +81,8 @@ results: BackupResults ): Promise { const { type } = backup; - - // Shorthand functions - const warn = results.addWarningMessage.bind(results, "wallets", uuid); - const success = results.addSuccessMessage.bind(results, "wallets", uuid); - - // Warnings to only be added if the wallet was actually added - const importWarnings: MessageType[] = []; - const importWarn = (msg: MessageType) => { - debug("v1 wallet %s added import warning:", uuid, msg); - - // Prepend the i18n key if it was just a string - importWarnings.push(typeof msg === "string" - ? "import.walletMessages." + msg - : msg); - }; - - /** Asserts that `val` is a truthy string */ - const str = (val: any): val is string => val && typeof val === "string"; + const shorthands = getShorthands(results, uuid, "v1"); + const { success, importWarn } = shorthands; // --------------------------------------------------------------------------- // DECRYPTION, BASIC VALIDATION @@ -122,9 +102,6 @@ await to((async () => JSON.parse(dec))()); if (err) throw new BackupWalletError("errorWalletJSON"); - // Removed for #25 - // debug("v1 wallet %s full data:", uuid, wallet); - // Validate the type of the decrypted wallet data if (!isPlainObject(wallet)) { debug("v1 wallet %s had decrypted type %s", uuid, typeof wallet); @@ -134,19 +111,7 @@ // --------------------------------------------------------------------------- // REQUIRED PROPERTY VALIDATION // --------------------------------------------------------------------------- - // Check if the wallet format is present - if (!str(wallet.format)) throw new BackupWalletError("errorFormatMissing"); - - // Check if the wallet format is supported (converting the old format name - // `krist` to `api` if necessary) - const format = _upgradeFormatName(wallet.format); - if (!WALLET_FORMATS[format]) throw new BackupWalletError("errorUnknownFormat"); - - // If the wallet format requires a username, check that the username is - // actually present - const { username } = wallet; - if (formatNeedsUsername(format) && !str(username)) - throw new BackupWalletError("errorUsernameMissing"); + const { format, username } = checkFormat(shorthands, wallet); // The password and masterkey must be present const { password, masterkey } = wallet; @@ -165,43 +130,19 @@ // Check if the wallet was using a custom icon if (str(wallet.icon)) importWarn("warningIcon"); - // Check that the label is valid for KristWeb v2 - // NOTE: this length check is unlikely to fail for KristWeb v1 imports - // because the label max length is set to 30, though it looks like that - // limit was added retroactively, so there may be some violating - // wallets. Better safe than sorry. - // See: https://github.com/tmpim/KristWeb/commit/82cab97 + // Check that the label is valid const { label } = wallet; - const labelValid = str(label) && label.trim().length < 32; - if (label && !labelValid) importWarn("warningLabelInvalid"); - - // Check if the wallet is using an advanced (unsupported) format - if (ADVANCED_FORMATS.includes(format)) - importWarn({ key: "import.walletMessages.warningAdvancedFormat", args: { format }}); + checkLabelValid(shorthands, label); // --------------------------------------------------------------------------- // WALLET IMPORT PREPARATION/VALIDATION // --------------------------------------------------------------------------- - // Calculate the address in advance, to check for existing wallets - const { privatekey, address } = await calculateAddress( - addressPrefix, - format || "kristwallet", - password, - username + const { address, existingWallet, existingImportWallet } = await checkAddress( + addressPrefix, existingWallets, results, + masterkey, "errorMasterKeyMismatch", + format, password, username ); - // Check that our calculated privatekey is actually equal to the stored - // masterkey. In practice these should never be different. - if (privatekey !== masterkey) - throw new BackupWalletError("errorMasterKeyMismatch"); - - // Check if a wallet already exists, either in the Redux store, or our list of - // imported wallets during this backup import - const existingWallet = Object.values(existingWallets) - .find(w => w.address === address); - const existingImportWallet = results.importedWallets - .find(w => w.address === address); - // Skip with no additional checks or updates if this wallet was already // handled by this backup import if (existingImportWallet) { @@ -212,61 +153,10 @@ // --------------------------------------------------------------------------- // WALLET IMPORT // --------------------------------------------------------------------------- - // Handle duplicate wallets - if (existingWallet) { - // The wallet already exists in the Redux store, so determine if we need to - // update its label (only if it was determined to be valid) - if (labelValid && existingWallet.label !== label!.trim()) { - if (noOverwrite) { - // The user didn't want to overwrite the wallet, so skip it - results.skippedWallets++; - return success({ key: "import.walletMessages.successSkippedNoOverwrite", args: { address }}); - } else { - const newLabel = label!.trim(); - debug( - "changing existing wallet %s (%s) label from %s to %s", - existingWallet.id, existingWallet.address, - existingWallet.label, newLabel - ); - - await editWalletLabel(existingWallet, newLabel); - - return success({ key: "import.walletMessages.successUpdated", args: { address, label: newLabel }}); - } - } else { - results.skippedWallets++; - return success({ key: "import.walletMessages.successSkipped", args: { address }}); - } - } - - // No existing wallet to update/skip, so now check if we can add it without - // going over the wallet limit - const currentWalletCount = - Object.keys(existingWallets).length + results.importedWallets.length; - if (currentWalletCount >= ADDRESS_LIST_LIMIT) - throw new BackupWalletError("errorLimitReached"); - - // Now that we're actually importing the wallet, push any warnings that may - // have been generated - importWarnings.forEach(warn); - - const newWalletData: WalletNew = { - label, - // Password is provided to addWallet for encryption - username, - format, - }; - - debug("adding new wallet %s", address); - const newWallet = await addWallet( - addressPrefix, appMasterPassword, - newWalletData, password, - true + await finalWalletImport( + existingWallets, appMasterPassword, addressPrefix, + shorthands, results, noOverwrite, + existingWallet, address, password, + { label, username, format } ); - debug("new wallet %s (%s)", newWallet.id, newWallet.address); - - // Add it to the results - results.newWallets++; - results.importedWallets.push(newWallet); // To keep track of limits - return success("import.walletMessages.success"); } diff --git a/src/pages/backup/backupImportV2.ts b/src/pages/backup/backupImportV2.ts new file mode 100644 index 0000000..5ad9dac --- /dev/null +++ b/src/pages/backup/backupImportV2.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import { BackupKristWebV2, KristWebV2Wallet } from "./backupFormats"; +import { BackupWalletError, BackupResults } from "./backupResults"; +import { + getShorthands, str, checkFormat, checkAddress, checkLabelValid, + checkCategoryValid, finalWalletImport +} from "./backupImportUtils"; + +import { WalletMap, decryptWallet } from "@wallets"; + +import { isPlainObject } from "lodash-es"; + +import Debug from "debug"; +const debug = Debug("kristweb:backup-import-v2"); + +const UUID_REGEXP = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/; + +/** Imports a KristWeb v2 backup. */ +export async function importV2Backup( + // Things regarding the app's existing state + existingWallets: WalletMap, + appMasterPassword: string, + addressPrefix: string, + + // Things related to the backup + backup: BackupKristWebV2, + masterPassword: string, + noOverwrite: boolean, + + results: BackupResults +): Promise { + // Import wallets + for (const uuid in backup.wallets) { + if (!uuid || !UUID_REGEXP.test(uuid)) { + // Not a wallet + debug("skipping v2 wallet key %s", uuid); + continue; + } + + const rawWallet = backup.wallets[uuid]; + debug("importing v2 wallet uuid %s", uuid); + + try { + await importV2Wallet( + existingWallets, appMasterPassword, addressPrefix, + masterPassword, noOverwrite, + uuid, rawWallet, + results + ); + } catch (err) { + debug("error importing v2 wallet", err); + results.addErrorMessage("wallets", uuid, undefined, err); + } + } + + // TODO: Import contacts +} + +/** Imports a single wallet in the KristWeb v2 format. */ +export async function importV2Wallet( + // Things regarding the app's existing state + existingWallets: WalletMap, + appMasterPassword: string, + addressPrefix: string, + + // Things related to the backup + masterPassword: string, + noOverwrite: boolean, + + uuid: string, + wallet: KristWebV2Wallet, // The wallet object as found in the backup + + results: BackupResults +): Promise { + const shorthands = getShorthands(results, uuid, "v2"); + const { success } = shorthands; + + // --------------------------------------------------------------------------- + // REQUIRED PROPERTY VALIDATION + // --------------------------------------------------------------------------- + // Validate the type of the wallet data + if (!isPlainObject(wallet)) { + debug("v2 wallet %s had type %s", uuid, typeof wallet, wallet); + throw new BackupWalletError("errorInvalidTypeObject"); + } + + const { format, username } = checkFormat(shorthands, wallet); + + // The encrypted password and private key must be present + const { encPassword, encPrivatekey } = wallet; + if (!str(encPassword)) throw new BackupWalletError("errorPasswordMissing"); + if (!str(encPrivatekey)) throw new BackupWalletError("errorPrivateKeyMissing"); + + // --------------------------------------------------------------------------- + // OPTIONAL PROPERTY VALIDATION + // --------------------------------------------------------------------------- + // Check that the label and category are valid + const { label, category } = wallet; + checkLabelValid(shorthands, label); + checkCategoryValid(shorthands, category); + + // --------------------------------------------------------------------------- + // WALLET IMPORT PREPARATION/VALIDATION + // --------------------------------------------------------------------------- + // Attempt to decrypt the password and private key + const dec = await decryptWallet(masterPassword, wallet); + if (!dec) throw new BackupWalletError("errorDecrypt"); + const { password, privatekey } = dec; + + const { address, existingWallet, existingImportWallet } = await checkAddress( + addressPrefix, existingWallets, results, + privatekey, "errorPrivateKeyMismatch", + format, password, username + ); + + // Skip with no additional checks or updates if this wallet was already + // handled by this backup import + if (existingImportWallet) { + results.skippedWallets++; + return success({ key: "import.walletMessages.successImportSkipped", args: { address }}); + } + // --------------------------------------------------------------------------- + // WALLET IMPORT + // --------------------------------------------------------------------------- + await finalWalletImport( + existingWallets, appMasterPassword, addressPrefix, + shorthands, results, noOverwrite, + existingWallet, address, password, + { label, category, username, format } + ); +} diff --git a/src/pages/dev/DevPage.tsx b/src/pages/dev/DevPage.tsx index a2df974..f60bc39 100644 --- a/src/pages/dev/DevPage.tsx +++ b/src/pages/dev/DevPage.tsx @@ -27,5 +27,10 @@ }}> Delete all wallets with zero balance + + {/* Delete all wallets */} + ; }