diff --git a/.vscode/settings.json b/.vscode/settings.json index d66c1d4..07badc1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -34,6 +34,7 @@ "languagedetector", "linkify", "localisation", + "masterkey", "memoises", "metaname", "middot", diff --git a/package.json b/package.json index cfacd76..821e1d9 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@testing-library/user-event": "^12.8.0", "antd": "^4.13.0", "async-mutex": "^0.3.1", + "await-to-js": "^2.1.1", "base64-arraybuffer": "^0.2.0", "chart.js": "^2.9.4", "classnames": "^2.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef5faf0..6f41b06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ '@testing-library/user-event': 12.8.0 antd: 4.13.0_89622fd8e4ec221151a62783d49305af async-mutex: 0.3.1 + await-to-js: 2.1.1 base64-arraybuffer: 0.2.0 chart.js: 2.9.4 classnames: 2.2.6 @@ -3146,6 +3147,12 @@ hasBin: true resolution: integrity: sha512-XrvP4VVHdRBCdX1S3WXVD8+RyG9qeb1D5Sn1DeLiG2xfSpzellk5k54xbUERJ3M5DggQxes39UGOTP8CFrEGbg== + /await-to-js/2.1.1: + dev: false + engines: + node: '>=6.0.0' + resolution: + integrity: sha512-CHBC6gQGCIzjZ09tJ+XmpQoZOn4GdWePB4qUweCaKNJ0D3f115YdhmYVTZ4rMVpiJ3cFzZcTYK1VMYEICV4YXw== /aws-sign2/0.7.0: dev: true resolution: @@ -13814,6 +13821,7 @@ antd: ^4.13.0 antd-dayjs-webpack-plugin: ^1.0.6 async-mutex: ^0.3.1 + await-to-js: ^2.1.1 babel-plugin-lodash: ^3.3.4 base64-arraybuffer: ^0.2.0 chart.js: ^2.9.4 diff --git a/public/locales/en.json b/public/locales/en.json index f5fe247..3a0965d 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -623,13 +623,17 @@ "successUpdated": "A wallet with the same address ({{address}}) already exists. Its label was updated to \"{{label}}\"", "successSkippedNoOverwrite": "A wallet with the same address ({{address}}) already exists, and you chose not to overwrite the label, so it was skipped.", - "warningSyncNode": "This wallet had a custom sync node, which is not supported in KristWeb v2. It was changed to the current sync node (<1 />).", - "warningIcon": "This wallet had a custom icon, which is not supported in KristWeb v2. It was skipped.", - "warningLabelInvalid": "The label for this wallet was invalid. It was skipped.", - "warningCategoryInvalid": "The label for this wallet was invalid. It was skipped.", + "warningSyncNode": "This wallet had a custom sync node, which is not supported in KristWeb v2. The sync node was skipped.", + "warningIcon": "This wallet had a custom icon, which is not supported in KristWeb v2. The icon was skipped.", + "warningLabelInvalid": "The label for this wallet was invalid. The label was skipped.", + "warningCategoryInvalid": "The category for this wallet was invalid. The category was skipped.", + "warningAdvancedFormat": "This wallet uses an advanced format ({{format}}), which has limited support in KristWeb v2.", + "errorInvalidTypeString": "This wallet was not a string!", + "errorInvalidTypeObject": "This wallet was not an object!", "errorDecrypt": "This wallet could not be decrypted!", "errorPasswordDecrypt": "The password for this wallet could not be decrypted!", + "errorWalletJSON": "The decrypted data was not valid JSON!", "errorUnknownFormat": "This wallet uses an unknown or unsupported format!", "errorFormatMissing": "This wallet is missing a format!", "errorUsernameMissing": "This wallet is missing a username!", diff --git a/src/components/wallets/SelectWalletFormat.tsx b/src/components/wallets/SelectWalletFormat.tsx index 4346849..ed10057 100644 --- a/src/components/wallets/SelectWalletFormat.tsx +++ b/src/components/wallets/SelectWalletFormat.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; -import { WalletFormatName, ADVANCED_FORMATS } from "@wallets/formats/WalletFormat"; +import { WalletFormatName, ADVANCED_FORMATS } from "@wallets/WalletFormat"; import { useBooleanSetting } from "@utils/settings"; interface Props { diff --git a/src/krist/wallets/Wallet.ts b/src/krist/wallets/Wallet.ts index d05fbc9..a46058f 100644 --- a/src/krist/wallets/Wallet.ts +++ b/src/krist/wallets/Wallet.ts @@ -3,7 +3,7 @@ // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { v4 as uuid } from "uuid"; -import { applyWalletFormat, WalletFormatName } from "./formats/WalletFormat"; +import { applyWalletFormat, WalletFormatName } from "./WalletFormat"; import { makeV2Address } from "../AddressAlgo"; import { aesGcmDecrypt, aesGcmEncrypt } from "@utils/crypto"; diff --git a/src/krist/wallets/WalletFormat.ts b/src/krist/wallets/WalletFormat.ts new file mode 100644 index 0000000..b754268 --- /dev/null +++ b/src/krist/wallets/WalletFormat.ts @@ -0,0 +1,35 @@ +// 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 { sha256 } from "@utils/crypto"; + +export interface WalletFormat { + (password: string, username?: string): Promise; +} + +export type WalletFormatName = "kristwallet" | "kristwallet_username_appendhashes" | "kristwallet_username" | "jwalelset" | "api"; +export const WALLET_FORMATS: Record = { + "kristwallet": async password => + await sha256("KRISTWALLET" + password) + "-000", + + "kristwallet_username_appendhashes": async (password, username) => + await sha256("KRISTWALLETEXTENSION" + await sha256(await sha256(username || "") + "^" + await sha256(password))) + "-000", + + "kristwallet_username": async (password, username) => + await sha256(await sha256(username || "") + "^" + await sha256(password)), + + "jwalelset": async password => + await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(password)))))))))))))))))), + + "api": async password => password +}; +export const ADVANCED_FORMATS: WalletFormatName[] = [ + "kristwallet_username_appendhashes", "kristwallet_username", "jwalelset" +]; + +export const applyWalletFormat = + (format: WalletFormatName, password: string, username?: string): Promise => + WALLET_FORMATS[format](password, username); + +export const formatNeedsUsername = (format: WalletFormatName): boolean => + WALLET_FORMATS[format].length === 2; diff --git a/src/krist/wallets/formats/WalletFormat.ts b/src/krist/wallets/formats/WalletFormat.ts deleted file mode 100644 index b754268..0000000 --- a/src/krist/wallets/formats/WalletFormat.ts +++ /dev/null @@ -1,35 +0,0 @@ -// 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 { sha256 } from "@utils/crypto"; - -export interface WalletFormat { - (password: string, username?: string): Promise; -} - -export type WalletFormatName = "kristwallet" | "kristwallet_username_appendhashes" | "kristwallet_username" | "jwalelset" | "api"; -export const WALLET_FORMATS: Record = { - "kristwallet": async password => - await sha256("KRISTWALLET" + password) + "-000", - - "kristwallet_username_appendhashes": async (password, username) => - await sha256("KRISTWALLETEXTENSION" + await sha256(await sha256(username || "") + "^" + await sha256(password))) + "-000", - - "kristwallet_username": async (password, username) => - await sha256(await sha256(username || "") + "^" + await sha256(password)), - - "jwalelset": async password => - await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(await sha256(password)))))))))))))))))), - - "api": async password => password -}; -export const ADVANCED_FORMATS: WalletFormatName[] = [ - "kristwallet_username_appendhashes", "kristwallet_username", "jwalelset" -]; - -export const applyWalletFormat = - (format: WalletFormatName, password: string, username?: string): Promise => - WALLET_FORMATS[format](password, username); - -export const formatNeedsUsername = (format: WalletFormatName): boolean => - WALLET_FORMATS[format].length === 2; diff --git a/src/pages/backup/ImportBackupModal.tsx b/src/pages/backup/ImportBackupModal.tsx index 6780546..1365753 100644 --- a/src/pages/backup/ImportBackupModal.tsx +++ b/src/pages/backup/ImportBackupModal.tsx @@ -167,8 +167,9 @@ , diff --git a/src/pages/backup/backupFormats.ts b/src/pages/backup/backupFormats.ts index 0fc6f2d..a74ec0f 100644 --- a/src/pages/backup/backupFormats.ts +++ b/src/pages/backup/backupFormats.ts @@ -2,6 +2,7 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { Wallet } from "@wallets/Wallet"; +import { WalletFormatName } from "@wallets/WalletFormat"; // The values here are the translation keys for the formats. export enum BackupFormatType { @@ -17,16 +18,47 @@ tester: string; } +// ============================================================================= +// KristWeb v1 +// ============================================================================= + +// https://github.com/tmpim/KristWeb/blob/696a402690cb4a317234ecd59ed85d7f03de1b70/src/js/wallet/model.js +export interface KristWebV1Wallet { + address?: string; + label?: string; + icon?: string; + username?: string; + password?: string; + masterkey?: string; + format?: string; + syncNode?: string; + balance?: number; + position?: number; +} + export interface BackupKristWebV1 extends Backup { + type: BackupFormatType.KRISTWEB_V1; + // KristWeb v1 backups contain a map of wallets, where the values are // encrypted JSON. wallets: Record; friends: Record; } +export const isBackupKristWebV1 = (backup: Backup): backup is BackupKristWebV1 => + backup.type === BackupFormatType.KRISTWEB_V1; + +// ============================================================================= +// KristWeb v2 +// ============================================================================= + +export type KristWebV2Wallet = Wallet; export interface BackupKristWebV2 extends Backup { + type: BackupFormatType.KRISTWEB_V2; version: 2; - wallets: Record; + wallets: Record; // friends: Record; } +export const isBackupKristWebV2 = (backup: Backup): backup is BackupKristWebV2 => + backup.type === BackupFormatType.KRISTWEB_V2; diff --git a/src/pages/backup/backupImport.tsx b/src/pages/backup/backupImport.tsx index 151f2f4..ebde259 100644 --- a/src/pages/backup/backupImport.tsx +++ b/src/pages/backup/backupImport.tsx @@ -6,8 +6,9 @@ import { aesGcmDecrypt } from "@utils/crypto"; import { decryptCryptoJS } from "@utils/CryptoJS"; -import { Backup, BackupFormatType } from "./backupFormats"; +import { Backup, BackupFormatType, isBackupKristWebV1 } from "./backupFormats"; import { BackupResults } from "./backupResults"; +import { importV1Wallet } from "./backupImportV1"; import Debug from "debug"; const debug = Debug("kristweb:backup-import"); @@ -84,5 +85,30 @@ // The results instance to keep track of logged messages, etc. const results = new BackupResults(); + // Attempt to add the wallets + if (isBackupKristWebV1(backup)) { + // Import wallets from a KristWeb v1 backup + for (const uuid in backup.wallets) { + if (!uuid || !uuid.startsWith("Wallet-")) { + // Not a wallet + debug("skipping v1 wallet key %s", uuid); + continue; + } + + const rawWallet = backup.wallets[uuid]; + debug("importing v1 wallet uuid %s: %o", uuid, rawWallet); + + try { + await importV1Wallet(backup, masterPassword, uuid, rawWallet, results); + } catch (err) { + debug("error importing v1 wallet", err); + results.addErrorMessage("wallets", uuid, undefined, err); + } + } + } else { + debug("WTF: unsupported backup format %s", backup.type); + } + + debug("import finished, final results:", results); return results; } diff --git a/src/pages/backup/backupImportV1.ts b/src/pages/backup/backupImportV1.ts new file mode 100644 index 0000000..b1155b5 --- /dev/null +++ b/src/pages/backup/backupImportV1.ts @@ -0,0 +1,121 @@ +// 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 { store } from "@app"; + +import { BackupKristWebV1, KristWebV1Wallet } from "./backupFormats"; +import { BackupWalletError, BackupResults, MessageType } from "./backupResults"; +import { backupDecryptValue } from "./backupImport"; + +import { + WALLET_FORMATS, ADVANCED_FORMATS, WalletFormatName, formatNeedsUsername +} from "@wallets/WalletFormat"; + +import { isPlainObject, memoize } from "lodash-es"; +import to from "await-to-js"; + +import Debug from "debug"; +const debug = Debug("kristweb:backup-import-v1"); + +/** 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 single wallet in the KristWeb v1 format. + */ +export async function importV1Wallet( + backup: BackupKristWebV1, + masterPassword: string, + uuid: string, + rawWallet: string, // The encrypted wallet + 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); + importWarnings.push(msg); + }; + + /** Asserts that `val` is a truthy string */ + const str = (val: any): val is string => val && typeof val === "string"; + + // --------------------------------------------------------------------------- + // DECRYPTION, BASIC VALIDATION + // --------------------------------------------------------------------------- + // Validate the type of the wallet data + if (!str(rawWallet)) { + debug("v1 wallet %s had type %s", uuid, typeof rawWallet, rawWallet); + throw new BackupWalletError("errorInvalidTypeString"); + } + + // Attempt to decrypt the wallet + const dec = await backupDecryptValue(type, masterPassword, rawWallet); + if (dec === false) throw new BackupWalletError("errorDecrypt"); + + // Parse JSON, promisify to catch syntax errors + const [err, wallet]: [Error | null, KristWebV1Wallet] = + await to((async () => JSON.parse(dec))()); + if (err) throw new BackupWalletError("errorWalletJSON"); + + 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, wallet); + throw new BackupWalletError("errorInvalidTypeObject"); + } + + // --------------------------------------------------------------------------- + // 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"); + + // The password and masterkey must be present + const { password, masterkey } = wallet; + if (!str(password)) throw new BackupWalletError("errorPasswordMissing"); + if (!str(masterkey)) throw new BackupWalletError("errorMasterKeyMissing"); + + // --------------------------------------------------------------------------- + // OPTIONAL PROPERTY VALIDATION + // --------------------------------------------------------------------------- + // Check if the wallet was using a custom sync node (ignoring http/https) + const appSyncNode = _cleanSyncNode(store.getState().node.syncNode); + const { syncNode } = wallet; + if (syncNode && _cleanSyncNode(syncNode) !== appSyncNode) + importWarn("warningSyncNode"); + + // Check if the wallet was using a custom icon + if (str(wallet.icon)) importWarn("warningIcon"); + + // Check that the label is valid for KristWeb v2 + 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 }}); +} diff --git a/src/pages/backup/backupResults.ts b/src/pages/backup/backupResults.ts index 0f5f6db..4192e75 100644 --- a/src/pages/backup/backupResults.ts +++ b/src/pages/backup/backupResults.ts @@ -6,8 +6,13 @@ import Debug from "debug"; const debug = Debug("kristweb:backup-results"); +export interface TranslatedMessage { + key: string; + args?: Record; +} + export type MessageSource = "wallets" | "friends"; -export type MessageType = React.ReactNode | string; +export type MessageType = React.ReactNode | TranslatedMessage | string; export type ResultType = "success" | "warning" | "error"; export class BackupResults { @@ -50,7 +55,7 @@ /** Logs an error message for the given wallet/friend UUID to the appropriate * message map. */ - public addErrorMessage(src: MessageSource, uuid: string, message: MessageType, error?: Error): void { + public addErrorMessage(src: MessageSource, uuid: string, message?: MessageType, error?: Error): void { this.addMessage(src, uuid, { type: "error", message, error }); } @@ -68,9 +73,13 @@ export interface BackupMessage { type: ResultType; error?: Error; - message: MessageType; + message?: MessageType; } export class BackupError extends Error { constructor(message: string) { super(message); } } + +export class BackupWalletError extends BackupError { + constructor(message: string) { super("import.walletMessages." + message); } +} diff --git a/src/pages/wallets/AddWalletModal.tsx b/src/pages/wallets/AddWalletModal.tsx index a4f6648..8e09531 100644 --- a/src/pages/wallets/AddWalletModal.tsx +++ b/src/pages/wallets/AddWalletModal.tsx @@ -15,7 +15,7 @@ import { CopyInputButton } from "@comp/CopyInputButton"; import { SelectWalletCategory } from "@comp/wallets/SelectWalletCategory"; -import { WalletFormatName, applyWalletFormat, formatNeedsUsername } from "@wallets/formats/WalletFormat"; +import { WalletFormatName, applyWalletFormat, formatNeedsUsername } from "@wallets/WalletFormat"; import { SelectWalletFormat } from "@comp/wallets/SelectWalletFormat"; import { makeV2Address } from "@krist/AddressAlgo"; import { useWallets, addWallet, decryptWallet, editWallet, Wallet, ADDRESS_LIST_LIMIT } from "@wallets/Wallet";