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:0> <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 wallet0> was imported.",
+ "walletsImported_plural": "<0>{{count, number}} new wallets0> were imported.",
+ "walletsSkipped": "{{count, number}} wallet was skipped.",
+ "walletsSkipped_plural": "{{count, number}} wallets were skipped.",
+
+ "friendsImported": "<0>{{count, number}} new friend0> was imported.",
+ "friendsImported_plural": "<0>{{count, number}} new friends0> were imported.",
+ "friendsSkipped": "{{count, number}} friend was skipped.",
+ "friendsSkipped_plural": "{{count, number}} friends were skipped.",
+
+ "warnings": "There was <1>{{count, number}} warning1> while importing your backup.",
+ "warnings_plural": "There were <1>{{count, number}} warning1> while importing your backup.",
+ "errors": "There was <1>{{count, number}} error1> while importing your backup.",
+ "errors_plural": "There were <1>{{count, number}} errors1> while importing your backup.",
+ "errorsAndWarnings": "There were <1>{{errors, number}} error(s)1> and <3>{{warnings, number}} warning(s)3> 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 */}
+ ;
+}