diff --git a/public/locales/en.json b/public/locales/en.json index 3aea293..4fecf0d 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -571,5 +571,88 @@ "apiErrorResult": { "resultUnknownTitle": "Unknown error", "resultUnknown": "See console for details." + }, + + "backups": { + "importButton": "Import backup", + "exportButton": "Export backup" + }, + + "import": { + "description": "Paste the backup code (or import from a file below) and enter the corresponding master password.", + "masterPasswordPlaceholder": "Master password", + "masterPasswordRequired": "Master password is required.", + "masterPasswordIncorrect": "Master password is incorrect.", + "fromFileButton": "Import from file", + "textareaPlaceholder": "Paste backup code here", + "modalButton": "Import", + + "detectedFormat": "<0>Detected format: <2 />", + "detectedFormatKristWebV1": "KristWeb v1", + "detectedFormatKristWebV2": "KristWeb v2", + "detectedFormatInvalid": "Invalid!", + + "treeHeaderWallets": "Wallets", + "treeHeaderFriends": "Friends (Address Book)", + + "treeWallet": "Wallet {{id}}", + "treeFriend": "Friend {{id}}", + + "decodeErrors": { + "atob": "The backup could not be decoded as it is not valid base64!", + "json": "The backup could not be decoded as it is not valid JSON!", + "missingTester": "The backup could not be decoded as it is missing a tester key!", + "missingSalt": "The backup could not be decoded as it is missing a salt key!", + "invalidWallets": "The backup could not be decoded as the 'wallets' key is the wrong type!", + "invalidFriends": "The backup could not be decoded as the 'wallets' key is the wrong type!", + "unknown": "The backup could not be decoded due to an unknown error. See console for details." + }, + + "walletMessages": { + "success": "Wallet imported successfully.", + "successSkipped": "A wallet with the same address ({{address}}) and settings already exists, so it was skipped.", + "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.", + + "errorDecrypt": "This wallet could not be decrypted!", + "errorPasswordDecrypt": "The password for this wallet could not be decrypted!", + "errorUnknownFormat": "This wallet uses an unknown or unsupported format!", + "errorFormatMissing": "This wallet is missing a format!", + "errorUsernameMissing": "This wallet is missing a username!", + "errorPasswordMissing": "This wallet is missing a password!", + "errorPrivateKeyMissing": "This wallet is missing a private key!", + "errorMasterKeyMissing": "This wallet is missing a master key!", + "errorLimitReached": "You reached the wallet limit. You currently cannot add any more wallets.", + "errorUnknown": "An unknown error occurred. See console for details." + }, + + "friendMessages": { + "errorNYI": "The Address Book has not yet been implemented, so it was skipped." + }, + + "results": { + "noneImported": "No new wallets were imported.", + + "walletsImported": "<0>{{count, number}} new wallet was imported.", + "walletsImported_plural": "<0>{{count, number}} new wallets were imported.", + "walletsSkipped": "{{count, number}} wallet was skipped.", + "walletsSkipped_plural": "{{count, number}} wallets were skipped.", + + "friendsImported": "<0>{{count, number}} new friend was imported.", + "friendsImported_plural": "<0>{{count, number}} new friends were imported.", + "friendsSkipped": "{{count, number}} friend was skipped.", + "friendsSkipped_plural": "{{count, number}} friends were skipped.", + + "warnings": "There was <1>{{count, number}} warning while importing your backup.", + "warnings_plural": "There were <1>{{count, number}} warning while importing your backup.", + "errors": "There was <1>{{count, number}} error while importing your backup.", + "errors_plural": "There were <1>{{count, number}} errors while importing your backup.", + "errorsAndWarnings": "There were <1>{{errors, number}} error(s) and <3>{{warnings, number}} warning(s) while importing your backup." + } } } diff --git a/src/global/AppRouter.tsx b/src/global/AppRouter.tsx index 4223701..0f3af54 100644 --- a/src/global/AppRouter.tsx +++ b/src/global/AppRouter.tsx @@ -20,6 +20,8 @@ import { CreditsPage } from "../pages/credits/CreditsPage"; +import { DevPage } from "../pages/dev/DevPage"; + import { NotFoundPage } from "../pages/NotFoundPage"; interface AppRoute { @@ -74,6 +76,9 @@ { path: "/settings/debug/translations", name: "settings", component: }, { path: "/credits", name: "credits", component: }, + + // TODO: remove this + { path: "/dev", name: "dev", component: } ]; export function AppRouter(): JSX.Element { diff --git a/src/pages/backup/backupFormats.ts b/src/pages/backup/backupFormats.ts new file mode 100644 index 0000000..d916029 --- /dev/null +++ b/src/pages/backup/backupFormats.ts @@ -0,0 +1,32 @@ +// 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 { Wallet } from "../../krist/wallets/Wallet"; + +// The values here are the translation keys for the formats. +export enum BackupFormatType { + KRISTWEB_V1 = "import.detectedFormatKristWebV1", + KRISTWEB_V2 = "import.detectedFormatKristWebV2" +} + +export interface BackupFormat { + // This value is inserted by `detectBackupFormat`. + type: BackupFormatType; + + salt: string; + tester: string; +} + +export interface BackupFormatKristWebV1 extends BackupFormat { + // KristWeb v1 backups contain a map of wallets, where the values are + // encrypted JSON. + wallets: Record; + friends: Record; +} + +export interface BackupFormatKristWebV2 extends BackupFormat { + version: 2; + + wallets: Record; + // friends: Record; +} diff --git a/src/pages/backup/backupImport.tsx b/src/pages/backup/backupImport.tsx new file mode 100644 index 0000000..a01e6e3 --- /dev/null +++ b/src/pages/backup/backupImport.tsx @@ -0,0 +1,63 @@ +// 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 React from "react"; + +import { aesGcmDecrypt } from "../../utils/crypto"; +import { decryptCryptoJS } from "../../utils/CryptoJS"; + +import { BackupFormatType } from "./backupFormats"; + +import Debug from "debug"; +const debug = Debug("kristweb:backup-import"); + +export interface BackupResults { + newWallets: number; + skippedWallets: number; + + messages: { + wallets: Record; + friends?: Record; + }; +} + +export interface BackupMessage { + type: "success" | "warning" | "error"; + error?: Error; + message: React.ReactNode; +} + +export class BackupError extends Error { + constructor(message: string) { super(message); } +} + +/** + * Attempts to decrypt a given value using the appropriate function for the + * backup format, with the `masterPassword` as the password. Returns `false` if + * decryption failed for any reason. + */ +async function backupDecryptValue( + format: BackupFormatType, + masterPassword: string, + value: string +): Promise { + try { + switch (format) { + // KristWeb v1 used Crypto.JS for its cryptography (SubtleCrypto was not + // yet widespread enough), which uses its own funky key derivation + // algorithm. Use our polyfill for it. + // For more info, see `utils/CryptoJS.ts`. + case BackupFormatType.KRISTWEB_V1: + return decryptCryptoJS(value, masterPassword); + + // KristWeb v2 simply uses WebCrypto/SubtleCrypto. + // For more info, see `utils/crypto.ts`. + case BackupFormatType.KRISTWEB_V2: + return aesGcmDecrypt(value, masterPassword); + } + } catch (err) { + debug("failed to decrypt backup value '%s'", value); + console.error(err); + return false; + } +} diff --git a/src/pages/backup/backupParser.ts b/src/pages/backup/backupParser.ts new file mode 100644 index 0000000..39a9fc5 --- /dev/null +++ b/src/pages/backup/backupParser.ts @@ -0,0 +1,48 @@ +// 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 { + BackupFormat, BackupFormatType, + BackupFormatKristWebV1, BackupFormatKristWebV2 +} from "./backupFormats"; + +import { isPlainObject } from "lodash-es"; + +export function detectBackupFormat(rawData: string): BackupFormat { + try { + // All backups are encoded as base64, so decode that first + const plainData = window.atob(rawData); + + // Attempt to parse JSON + const data = JSON.parse(plainData); + + // Check for required properties + if (!data.tester) throw new Error("import.decodeErrors.missingTester"); + if (!data.salt) throw new Error("import.decodeErrors.missingSalt"); + if (!isPlainObject(data.wallets)) + throw new Error("import.decodeErrors.invalidWallets"); + if (data.friends !== undefined && !isPlainObject(data.friends)) + throw new Error("import.decodeErrors.invalidFriends"); + + // Determine the format + if (data.version === 2) { + // KristWeb v2 + data.type = BackupFormatType.KRISTWEB_V2; + return data as BackupFormatKristWebV2; + } else { + // KristWeb v1 + data.type = BackupFormatType.KRISTWEB_V1; + return data as BackupFormatKristWebV1; + } + } catch (err) { + // Invalid base64 + if (err instanceof DOMException && err.name === "InvalidCharacterError") + throw new Error("import.decodeErrors.atob"); + + // Invalid json + if (err instanceof SyntaxError) + throw new Error("import.decodeErrors.json"); + + throw err; + } +} diff --git a/src/pages/dev/DevPage.tsx b/src/pages/dev/DevPage.tsx new file mode 100644 index 0000000..530291a --- /dev/null +++ b/src/pages/dev/DevPage.tsx @@ -0,0 +1,79 @@ +// 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 React, { useState } from "react"; +import { Input, Button, Typography } from "antd"; + +import { useTranslation, Trans } from "react-i18next"; + +import { PageLayout } from "../../layout/PageLayout"; + +import { BackupFormatType } from "../backup/backupFormats"; +import { detectBackupFormat } from "../backup/backupParser"; + +const { TextArea } = Input; +const { Text, Paragraph } = Typography; + +export function DevPage(): JSX.Element { + const { t } = useTranslation(); + + const [code, setCode] = useState(""); + const [detectedFormatType, setDetectedFormatType] = useState(); + const [decodeError, setDecodeError] = useState(); + + async function onImport() { + console.log(code); + try { + const format = detectBackupFormat(code); + setDecodeError(undefined); + setDetectedFormatType(format.type); + } catch (err) { + console.error(err); + + const message = err.message // Translate the error if we can + ? err.message.startsWith("import.") ? t(err.message) : err.message + : t("import.decodeErrors.unknown"); + + setDecodeError(message); + } + } + + // Display the detected format as a string, or "Invalid!" if there was an + // error decoding it. + function DetectFormatText(): JSX.Element | null { + if (decodeError) return {t("import.detectedFormatInvalid")}; + if (detectedFormatType) return {t(detectedFormatType)}; + return null; + } + + return + {/* Detected format */} + {(detectedFormatType || decodeError) ? ( + + + Detected format: + + + ) : <>} + + {/* Decode error */} + {decodeError && {decodeError}} + + {/* Backup code textarea */} +