diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 93a49db..95e0ea8 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -752,7 +752,7 @@ public async checkDebridAccounts(settingsOverride?: AppSettings, persistValidOve this.audit("INFO", "Download-Statistik zurückgesetzt"); } - public exportBackup(): Buffer { + public exportBackup(passphrase: string): Buffer { let remoteDiagnostics: BackupRemoteDiagnostics | undefined; if (Boolean(this.settings.backupIncludeRemoteDiagnostics)) { const status = getDebugServerRuntimeStatus(); @@ -770,13 +770,14 @@ public async checkDebridAccounts(settingsOverride?: AppSettings, persistValidOve history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()), remoteDiagnostics }); - this.audit("INFO", "Backup exportiert", { + const encrypted = encryptBackup(JSON.stringify(payloadObj), passphrase); + this.audit("INFO", "Backup exportiert", { kind: payloadObj.kind, historyEntries: payloadObj.history ? payloadObj.history.length : 0, sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0, sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0 }); - return encryptBackup(JSON.stringify(payloadObj)); + return encrypted; } public async exportOnlineBackup(): Promise<{ key: string }> { @@ -812,10 +813,10 @@ public async checkDebridAccounts(settingsOverride?: AppSettings, persistValidOve return getSupportBundleDefaultFileName(); } - public importBackup(data: Buffer): { restored: boolean; relaunch: boolean; message: string } { + public importBackup(data: Buffer, passphrase?: string): { restored: boolean; relaunch: boolean; message: string } { let parsed: Record; try { - const json = decryptBackup(data); + const json = decryptBackup(data, passphrase); parsed = JSON.parse(json) as Record; } catch { try { diff --git a/src/main/backup-crypto.ts b/src/main/backup-crypto.ts index aa76073..4c5aa4a 100644 --- a/src/main/backup-crypto.ts +++ b/src/main/backup-crypto.ts @@ -1,6 +1,6 @@ import crypto from "node:crypto"; -const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026"; +const LEGACY_APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026"; const ALGORITHM = "aes-256-gcm"; const KEY_LENGTH = 32; const SALT_LENGTH = 16; @@ -9,24 +9,26 @@ const AUTH_TAG_LENGTH = 16; const PREFIX = Buffer.from("MDD"); const LEGACY_MAGIC = Buffer.from("MDD1"); const MAGIC = Buffer.from("MDD2"); -const LEGACY_HEADER_LENGTH = LEGACY_MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH; -const HEADER_LENGTH = MAGIC.length + SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH; - -function deriveLegacyKey(): Buffer { - return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest(); -} - -function deriveKey(salt: Buffer): Buffer { - return crypto.scryptSync(APP_KEY_MATERIAL, salt, KEY_LENGTH); -} +const LEGACY_HEADER_LENGTH = LEGACY_MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH; +const HEADER_LENGTH = MAGIC.length + SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH; +const DECRYPTION_ERROR = "Backup-Datei konnte nicht entschlüsselt werden"; + +function deriveLegacyKey(): Buffer { + return crypto.createHash("sha256").update(LEGACY_APP_KEY_MATERIAL).digest(); +} + +function deriveKey(passphrase: string, salt: Buffer): Buffer { + return crypto.scryptSync(passphrase, salt, KEY_LENGTH); +} function decryptAuthenticated( ciphertext: Buffer, - key: Buffer, - iv: Buffer, - authTag: Buffer, - authenticatedData?: Buffer -): string { + key: Buffer, + iv: Buffer, + authTag: Buffer, + authenticatedData?: Buffer, + errorMessage = "Backup-Datei ist beschädigt oder konnte nicht authentifiziert werden" +): string { try { const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); if (authenticatedData) { @@ -34,8 +36,8 @@ function decryptAuthenticated( } decipher.setAuthTag(authTag); return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); - } catch { - throw new Error("Backup-Datei ist beschädigt oder konnte nicht authentifiziert werden"); + } catch { + throw new Error(errorMessage); } } @@ -54,10 +56,13 @@ function decryptLegacyBackup(data: Buffer): string { ); } -function decryptCurrentBackup(data: Buffer): string { - if (data.length < HEADER_LENGTH) { - throw new Error("Backup-Datei zu kurz oder ungültig"); - } +function decryptCurrentBackup(data: Buffer, passphrase?: string): string { + if (data.length < HEADER_LENGTH) { + throw new Error("Backup-Datei zu kurz oder ungültig"); + } + if (typeof passphrase !== "string" || passphrase.trim().length === 0) { + throw new Error(DECRYPTION_ERROR); + } const saltStart = MAGIC.length; const ivStart = saltStart + SALT_LENGTH; const authTagStart = ivStart + IV_LENGTH; @@ -66,30 +71,38 @@ function decryptCurrentBackup(data: Buffer): string { const iv = data.subarray(ivStart, authTagStart); return decryptAuthenticated( data.subarray(ciphertextStart), - deriveKey(salt), - iv, - data.subarray(authTagStart, ciphertextStart), - data.subarray(0, authTagStart) - ); -} - -export function encryptBackup(plaintext: string): Buffer { - const salt = crypto.randomBytes(SALT_LENGTH); - const iv = crypto.randomBytes(IV_LENGTH); - const key = deriveKey(salt); + deriveKey(passphrase, salt), + iv, + data.subarray(authTagStart, ciphertextStart), + data.subarray(0, authTagStart), + DECRYPTION_ERROR + ); +} + +export function encryptBackup(plaintext: string, passphrase: string): Buffer { + if (typeof passphrase !== "string" || passphrase.trim().length === 0) { + throw new Error("Backup-Passphrase erforderlich"); + } + const salt = crypto.randomBytes(SALT_LENGTH); + const iv = crypto.randomBytes(IV_LENGTH); + const key = deriveKey(passphrase, salt); const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); cipher.setAAD(Buffer.concat([MAGIC, salt, iv])); const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); - return Buffer.concat([MAGIC, salt, iv, cipher.getAuthTag(), encrypted]); -} - -export function decryptBackup(data: Buffer): string { + return Buffer.concat([MAGIC, salt, iv, cipher.getAuthTag(), encrypted]); +} + +export function isMdd2Backup(data: Buffer): boolean { + return data.length >= MAGIC.length && data.subarray(0, MAGIC.length).equals(MAGIC); +} + +export function decryptBackup(data: Buffer, passphrase?: string): string { if (data.length < MAGIC.length) { throw new Error("Backup-Datei zu kurz oder ungültig"); } - const magic = data.subarray(0, MAGIC.length); - if (magic.equals(MAGIC)) { - return decryptCurrentBackup(data); + const magic = data.subarray(0, MAGIC.length); + if (magic.equals(MAGIC)) { + return decryptCurrentBackup(data, passphrase); } if (magic.equals(LEGACY_MAGIC)) { return decryptLegacyBackup(data); diff --git a/src/main/main.ts b/src/main/main.ts index bee201e..190482d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -14,6 +14,7 @@ import { revealHistoryEntry } from "./history-reveal"; import { DEV_SERVER_URL } from "./dev-server-url"; import { resolveAppIconPath } from "./app-icon"; import { configureCredentialProtector } from "./credential-protection"; +import { isMdd2Backup } from "./backup-crypto"; function validateString(value: unknown, name: string): string { if (typeof value !== "string") { @@ -73,8 +74,9 @@ let tray: Tray | null = null; let clipboardTimer: ReturnType | null = null; let updateQuitTimer: ReturnType | null = null; let scheduledStartTimer: ReturnType | null = null; -let lastClipboardText = ""; +let lastClipboardText = ""; let controller: AppController; +let pendingBackupImport: Buffer | null = null; const CLIPBOARD_MAX_TEXT_CHARS = 50_000; function isDevMode(): boolean { @@ -580,7 +582,8 @@ function registerIpcHandlers(): void { app.quit(); }); - ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => { + ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async (_event: IpcMainInvokeEvent, rawPassphrase: unknown) => { + const passphrase = validateString(rawPassphrase, "passphrase"); const options = { defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`, filters: [{ name: "MDD Backup", extensions: ["mdd"] }] @@ -589,7 +592,7 @@ function registerIpcHandlers(): void { if (result.canceled || !result.filePath) { return { saved: false }; } - const encrypted = controller.exportBackup(); + const encrypted = controller.exportBackup(passphrase); await fs.promises.writeFile(result.filePath, encrypted); return { saved: true }; }); @@ -766,7 +769,8 @@ function registerIpcHandlers(): void { return controller.checkSingleMegaDebridAccount(String(login || ""), String(password || "")); }); - ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => { + ipcMain.handle(IPC_CHANNELS.SELECT_BACKUP_IMPORT, async () => { + pendingBackupImport = null; const options = { properties: ["openFile"] as Array<"openFile">, filters: [ @@ -775,18 +779,33 @@ function registerIpcHandlers(): void { { name: "Alle Dateien", extensions: ["*"] } ] }; - const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); - if (result.canceled || result.filePaths.length === 0) { - return { restored: false, message: "Abgebrochen" }; + const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); + if (result.canceled || result.filePaths.length === 0) { + return { selected: false, requiresPassphrase: false, message: "Abgebrochen" }; } const filePath = result.filePaths[0]; const stat = await fs.promises.stat(filePath); const BACKUP_MAX_BYTES = 50 * 1024 * 1024; - if (stat.size > BACKUP_MAX_BYTES) { - return { restored: false, message: `Backup-Datei zu groß (max 50 MB, Datei hat ${(stat.size / 1024 / 1024).toFixed(1)} MB)` }; - } - const data = await fs.promises.readFile(filePath); - const importResult = controller.importBackup(data); + if (stat.size > BACKUP_MAX_BYTES) { + return { selected: false, requiresPassphrase: false, message: `Backup-Datei zu groß (max 50 MB, Datei hat ${(stat.size / 1024 / 1024).toFixed(1)} MB)` }; + } + const data = await fs.promises.readFile(filePath); + pendingBackupImport = data; + return { selected: true, requiresPassphrase: isMdd2Backup(data) }; + }); + + ipcMain.handle(IPC_CHANNELS.CANCEL_BACKUP_IMPORT, () => { + pendingBackupImport = null; + }); + + ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async (_event: IpcMainInvokeEvent, rawPassphrase?: unknown) => { + const data = pendingBackupImport; + pendingBackupImport = null; + if (!data) { + return { restored: false, relaunch: false, message: "Keine Backup-Datei ausgewählt" }; + } + const passphrase = typeof rawPassphrase === "string" ? rawPassphrase : undefined; + const importResult = controller.importBackup(data, passphrase); // Only a full restore (queue swapped) needs the auto-relaunch. A settings- // only import applied live — relaunching would be pointless and would drop // the running queue. diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 3159151..546f1b2 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -61,8 +61,10 @@ const api: ElectronApi = { resetDownloadStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS), restart: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESTART), quit: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.QUIT), - exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), - importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), + exportBackup: (passphrase: string): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP, passphrase), + selectBackupImport: (): Promise<{ selected: boolean; requiresPassphrase: boolean; message?: string }> => ipcRenderer.invoke(IPC_CHANNELS.SELECT_BACKUP_IMPORT), + importBackup: (passphrase?: string): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP, passphrase), + cancelBackupImport: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_BACKUP_IMPORT), exportOnlineBackup: (): Promise<{ key: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ONLINE_BACKUP), importOnlineBackup: (key: string): Promise<{ restored: boolean; relaunch: false; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, key), exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 568b322..9f5b763 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -45,6 +45,7 @@ import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons"; import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state"; import { createUiLocalizer, normalizeLanguage } from "./i18n"; +import { runLocalBackupExport, runLocalBackupImport, type BackupPassphraseMode } from "./backup-flow"; import type { DownloadSpeedHistoryState } from "./download-speed-state"; import { extractHoster, formatDateTime, formatSpeedMbps, humanSize, providerLabels } from "./download-format"; import { AppShell } from "./shell/AppShell"; @@ -53,6 +54,7 @@ import { OverlayHost } from "./shell/OverlayHost"; import { UpdateExperience } from "./shell/UpdateExperience"; import type { MainView } from "./shell/shell-model"; import { ContextMenu } from "./ui/ContextMenu"; +import { BackupPassphraseDialog } from "./ui/BackupPassphraseDialog"; import { Dialog } from "./ui/Dialog"; import { Icon } from "./ui/Icon"; import { Toast } from "./ui/Toast"; @@ -1865,6 +1867,7 @@ export function App(): ReactElement { const [startConflictPrompt, setStartConflictPrompt] = useState(null); const startConflictResolverRef = useRef<((result: { policy: Extract; applyToAll: boolean } | null) => void) | null>(null); const [confirmPrompt, setConfirmPrompt] = useState(null); + const [backupPassphraseMode, setBackupPassphraseMode] = useState(null); const [onlineBackupDialog, setOnlineBackupDialog] = useState(null); const [remoteDiag, setRemoteDiag] = useState(null); const [remoteDiagOpen, setRemoteDiagOpen] = useState(false); @@ -1874,7 +1877,8 @@ export function App(): ReactElement { const [rdPort, setRdPort] = useState("9868"); const [rdAllowlist, setRdAllowlist] = useState(""); const [rdName, setRdName] = useState(""); - const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null); + const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null); + const backupPassphraseResolverRef = useRef<((passphrase: string | null) => void) | null>(null); const confirmQueueRef = useRef void }>>([]); const importQueueFocusHandlerRef = useRef<(() => void) | null>(null); const [contextMenu, setContextMenu] = useState(null); @@ -2262,11 +2266,16 @@ export function App(): ReactElement { startConflictResolverRef.current = null; resolver(null); } - if (confirmResolverRef.current) { + if (confirmResolverRef.current) { const resolver = confirmResolverRef.current; confirmResolverRef.current = null; - resolver(false); - } + resolver(false); + } + if (backupPassphraseResolverRef.current) { + const resolver = backupPassphraseResolverRef.current; + backupPassphraseResolverRef.current = null; + resolver(null); + } while (confirmQueueRef.current.length > 0) { const request = confirmQueueRef.current.shift(); request?.resolve(false); @@ -3422,6 +3431,20 @@ export function App(): ReactElement { }); }, [pumpConfirmQueue]); + const closeBackupPassphraseDialog = useCallback((passphrase: string | null): void => { + const resolver = backupPassphraseResolverRef.current; + backupPassphraseResolverRef.current = null; + setBackupPassphraseMode(null); + resolver?.(passphrase); + }, []); + + const askBackupPassphrase = useCallback((mode: BackupPassphraseMode): Promise => { + return new Promise((resolve) => { + backupPassphraseResolverRef.current = resolve; + setBackupPassphraseMode(mode); + }); + }, []); + const restoreHistoryEntries = useCallback(async (entryIds: string[]): Promise => { const requested = new Set(entryIds); const entries = historyEntriesRef.current.filter((entry) => requested.has(entry.id)); @@ -4519,7 +4542,7 @@ export function App(): ReactElement { const onExportBackup = async (): Promise => { closeMenus(); await performQuickAction(async () => { - const result = await window.rd.exportBackup(); + const result = await runLocalBackupExport(window.rd, askBackupPassphrase); if (result.saved) { showToast("Sicherung exportiert"); } @@ -4531,7 +4554,7 @@ export function App(): ReactElement { const onImportBackup = async (): Promise => { closeMenus(); await performQuickAction(async () => { - const result = await window.rd.importBackup(); + const result = await runLocalBackupImport(window.rd, askBackupPassphrase); if (result.restored) { showToast(result.message, 4000); // A settings-only import applies live without a relaunch, so the editable @@ -5987,6 +6010,13 @@ export function App(): ReactElement { closeBackupPassphraseDialog(null)} + onSubmit={(passphrase) => closeBackupPassphraseDialog(passphrase)} + /> + ) : null} confirm={confirmPrompt ? ( closeConfirmPrompt(false)} open title={confirmPrompt.title}>

{confirmPrompt.message}

diff --git a/src/renderer/backup-flow.ts b/src/renderer/backup-flow.ts new file mode 100644 index 0000000..09d39a2 --- /dev/null +++ b/src/renderer/backup-flow.ts @@ -0,0 +1,66 @@ +export interface BackupSelectionResult { + selected: boolean; + requiresPassphrase: boolean; + message?: string; +} + +export interface BackupImportResult { + restored: boolean; + relaunch: boolean; + message: string; +} + +export interface LocalBackupApi { + exportBackup: (passphrase: string) => Promise<{ saved: boolean }>; + selectBackupImport: () => Promise; + importBackup: (passphrase?: string) => Promise; + cancelBackupImport: () => Promise; +} + +export type BackupPassphraseMode = "export" | "import"; +export type BackupPassphraseRequest = (mode: BackupPassphraseMode) => Promise; + +export function validateBackupPassphrase( + mode: BackupPassphraseMode, + passphrase: string, + confirmation: string +): string | null { + if (passphrase.trim().length === 0) { + return "Bitte eine Passphrase eingeben"; + } + if (mode === "export" && passphrase !== confirmation) { + return "Die Passphrasen stimmen nicht überein"; + } + return null; +} + +export async function runLocalBackupExport( + api: LocalBackupApi, + requestPassphrase: BackupPassphraseRequest +): Promise<{ saved: boolean }> { + const passphrase = await requestPassphrase("export"); + if (passphrase === null) { + return { saved: false }; + } + return api.exportBackup(passphrase); +} + +export async function runLocalBackupImport( + api: LocalBackupApi, + requestPassphrase: BackupPassphraseRequest +): Promise { + const selection = await api.selectBackupImport(); + if (!selection.selected) { + return { restored: false, relaunch: false, message: selection.message || "Abgebrochen" }; + } + let passphrase: string | undefined; + if (selection.requiresPassphrase) { + const requested = await requestPassphrase("import"); + if (requested === null) { + await api.cancelBackupImport(); + return { restored: false, relaunch: false, message: "Abgebrochen" }; + } + passphrase = requested; + } + return api.importBackup(passphrase); +} diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index d130f2e..467fe98 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -133,6 +133,9 @@ const pairs = [ ["Start abgebrochen", "Start cancelled"], ["Keine gültigen Links gefunden", "No valid links found"], ["Keine gültigen Links in den DLC-Dateien gefunden", "No valid links found in the DLC files"], ["Keine gültigen Links in den Import-Dateien gefunden", "No valid links found in the import files"], ["Links per Drag-and-Drop eingefügt", "Links added by drag and drop"], ["Queue exportiert", "Queue exported"], ["Keine gültigen Links in der Datei gefunden", "No valid links found in the file"], ["Sicherung exportiert", "Backup exported"], ["Online-Schlüssel erstellt", "Online key created"], + ["Sicherung schützen", "Protect backup"], ["Sicherung entsperren", "Unlock backup"], ["Lege eine Passphrase für diese Sicherung fest. Sie wird nicht gespeichert und wird beim Import erneut benötigt.", "Set a passphrase for this backup. It is not stored and will be required again during import."], + ["Diese Sicherung ist mit einer Passphrase geschützt.", "This backup is protected with a passphrase."], ["Passphrase", "Passphrase"], ["Passphrase bestätigen", "Confirm passphrase"], + ["Bitte eine Passphrase eingeben", "Enter a passphrase"], ["Die Passphrasen stimmen nicht überein", "The passphrases do not match"], ["Sicherung exportieren", "Export backup"], ["Sicherung importieren", "Import backup"], ["Online-Sicherung konnte nicht erstellt werden.", "Online backup could not be created."], ["Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen.", "Online backup could not be loaded. Check the key and try again."], ["Online-Schlüssel kopiert", "Online key copied"], ["Schlüssel konnte nicht kopiert werden", "Key could not be copied"], ["Support-Bundle exportiert", "Support bundle exported"], ["Support-Trace für 2 Stunden aktiviert", "Support trace enabled for 2 hours"], ["Support-Trace deaktiviert", "Support trace disabled"], ["Keine akuten Warnungen", "No current warnings"], diff --git a/src/renderer/shell/OverlayHost.tsx b/src/renderer/shell/OverlayHost.tsx index 7ab1ad0..4267d7a 100644 --- a/src/renderer/shell/OverlayHost.tsx +++ b/src/renderer/shell/OverlayHost.tsx @@ -2,6 +2,7 @@ import type { ReactElement, ReactNode } from "react"; export interface OverlayHostProps { confirm?: ReactNode; + backupPassphrase?: ReactNode; onlineBackup?: ReactNode; diagnostics?: ReactNode; deleteConfirmation?: ReactNode; @@ -21,6 +22,7 @@ export interface OverlayHostProps { export function OverlayHost({ confirm, + backupPassphrase, onlineBackup, diagnostics, deleteConfirmation, @@ -40,6 +42,7 @@ export function OverlayHost({ return (
{confirm} + {backupPassphrase} {onlineBackup} {diagnostics} {deleteConfirmation} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index b3d1104..5c92b24 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -3269,6 +3269,46 @@ td { width: min(760px, calc(100vw - 24px)); } +.backup-passphrase-modal { + width: min(520px, calc(100vw - 24px)); +} + +.backup-passphrase-form, +.backup-passphrase-fields { + display: grid; + gap: 10px; +} + +.backup-passphrase-fields label { + display: grid; + gap: 5px; + color: var(--text); + font-size: 13px; + font-weight: 600; +} + +.backup-passphrase-fields input { + width: 100%; + min-height: 36px; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + box-sizing: border-box; + font: inherit; +} + +.backup-passphrase-fields input:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); + border-color: var(--accent); +} + +.backup-passphrase-error { + color: var(--danger); + font-size: 13px; +} + .online-backup-key { width: 100%; min-height: 86px; diff --git a/src/renderer/ui/BackupPassphraseDialog.tsx b/src/renderer/ui/BackupPassphraseDialog.tsx new file mode 100644 index 0000000..8039e15 --- /dev/null +++ b/src/renderer/ui/BackupPassphraseDialog.tsx @@ -0,0 +1,78 @@ +import { FormEvent, ReactElement, useState } from "react"; +import { BackupPassphraseMode, validateBackupPassphrase } from "../backup-flow"; +import { Dialog } from "./Dialog"; + +export interface BackupPassphraseDialogProps { + mode: BackupPassphraseMode; + onCancel: () => void; + onSubmit: (passphrase: string) => void; +} + +export function BackupPassphraseDialog({ mode, onCancel, onSubmit }: BackupPassphraseDialogProps): ReactElement { + const [passphrase, setPassphrase] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [error, setError] = useState(""); + + const clear = (): void => { + setPassphrase(""); + setConfirmation(""); + setError(""); + }; + + const cancel = (): void => { + clear(); + onCancel(); + }; + + const submit = (event: FormEvent): void => { + event.preventDefault(); + const validationError = validateBackupPassphrase(mode, passphrase, confirmation); + if (validationError) { + setError(validationError); + return; + } + const submittedPassphrase = passphrase; + clear(); + onSubmit(submittedPassphrase); + }; + + return ( + +

{mode === "export" ? "Lege eine Passphrase für diese Sicherung fest. Sie wird nicht gespeichert und wird beim Import erneut benötigt." : "Diese Sicherung ist mit einer Passphrase geschützt."}

+
+
+ + {mode === "export" && ( + + )} +
+ {error &&
{error}
} +
+ + +
+
+
+ ); +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index e620d1c..fc503ff 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -35,9 +35,11 @@ export const IPC_CHANNELS = { RESET_SESSION_STATS: "stats:reset-session", RESET_DOWNLOAD_STATS: "stats:reset-download", RESTART: "app:restart", - QUIT: "app:quit", + QUIT: "app:quit", EXPORT_BACKUP: "app:export-backup", + SELECT_BACKUP_IMPORT: "app:select-backup-import", IMPORT_BACKUP: "app:import-backup", + CANCEL_BACKUP_IMPORT: "app:cancel-backup-import", EXPORT_ONLINE_BACKUP: "app:export-online-backup", IMPORT_ONLINE_BACKUP: "app:import-online-backup", EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 676a113..d8f2748 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -58,8 +58,10 @@ export interface ElectronApi { resetDownloadStats: () => Promise; restart: () => Promise; quit: () => Promise; - exportBackup: () => Promise<{ saved: boolean }>; - importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>; + exportBackup: (passphrase: string) => Promise<{ saved: boolean }>; + selectBackupImport: () => Promise<{ selected: boolean; requiresPassphrase: boolean; message?: string }>; + importBackup: (passphrase?: string) => Promise<{ restored: boolean; relaunch: boolean; message: string }>; + cancelBackupImport: () => Promise; exportOnlineBackup: () => Promise<{ key: string }>; importOnlineBackup: (key: string) => Promise<{ restored: boolean; relaunch: false; message: string }>; exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>; diff --git a/tests/backup-crypto.test.ts b/tests/backup-crypto.test.ts index fd4da32..a2aaa25 100644 --- a/tests/backup-crypto.test.ts +++ b/tests/backup-crypto.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { encryptBackup, decryptBackup } from "../src/main/backup-crypto"; +import { encryptBackup, decryptBackup, isMdd2Backup } from "../src/main/backup-crypto"; + +const PASSPHRASE = "test-only backup passphrase"; describe("backup-crypto", () => { it("encrypts and decrypts a round-trip correctly", () => { @@ -10,46 +12,83 @@ describe("backup-crypto", () => { history: [{ id: "h1", name: "Test" }] }); - const encrypted = encryptBackup(original); - const decrypted = decryptBackup(encrypted); + const encrypted = encryptBackup(original, PASSPHRASE); + const decrypted = decryptBackup(encrypted, PASSPHRASE); expect(decrypted).toBe(original); }); it("produces binary output that is not plaintext readable", () => { const sensitiveValue = "value-that-must-not-be-readable"; const plaintext = JSON.stringify({ settings: { value: sensitiveValue } }); - const encrypted = encryptBackup(plaintext); + const encrypted = encryptBackup(plaintext, PASSPHRASE); expect(encrypted.toString("utf8")).not.toContain(sensitiveValue); expect(encrypted.toString("latin1")).not.toContain(sensitiveValue); }); it("writes the MDD2 backup format", () => { - const encrypted = encryptBackup("test"); + const encrypted = encryptBackup("test", PASSPHRASE); expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD2"); expect(encrypted.length).toBeGreaterThan(48); }); - it("reads the legacy backup format for migration", () => { - const legacy = Buffer.from("TUREMQcHBwcHBwcHBwcHB7h4ood1DE8Wc+BPgzE6EYdio3HAN/UB1Mru6Fmvtw==", "base64"); - expect(decryptBackup(legacy)).toBe("legacy payload"); - }); + it("reads the legacy backup format for migration", () => { + const legacy = Buffer.from("TUREMQcHBwcHBwcHBwcHB7h4ood1DE8Wc+BPgzE6EYdio3HAN/UB1Mru6Fmvtw==", "base64"); + expect(decryptBackup(legacy)).toBe("legacy payload"); + expect(decryptBackup(legacy, "ignored test passphrase")).toBe("legacy payload"); + }); + + it("detects only MDD2 backups as passphrase protected", () => { + const current = encryptBackup("test", PASSPHRASE); + const legacy = Buffer.from("TUREMQcHBwcHBwcHBwcHB7h4ood1DE8Wc+BPgzE6EYdio3HAN/UB1Mru6Fmvtw==", "base64"); + expect(isMdd2Backup(current)).toBe(true); + expect(isMdd2Backup(legacy)).toBe(false); + expect(isMdd2Backup(Buffer.from('{"version":2}', "utf8"))).toBe(false); + }); it("uses a new salt and IV for every encryption", () => { const plaintext = "same input data"; - const a = encryptBackup(plaintext); - const b = encryptBackup(plaintext); + const a = encryptBackup(plaintext, PASSPHRASE); + const b = encryptBackup(plaintext, PASSPHRASE); expect(a.equals(b)).toBe(false); expect(a.subarray(4, 20).equals(b.subarray(4, 20))).toBe(false); expect(a.subarray(20, 32).equals(b.subarray(20, 32))).toBe(false); - expect(decryptBackup(a)).toBe(plaintext); - expect(decryptBackup(b)).toBe(plaintext); - }); - - it("throws on truncated data", () => { - const encrypted = encryptBackup("test data"); - expect(() => decryptBackup(encrypted.subarray(0, 47))).toThrow(/zu kurz|ungültig/); - }); + expect(decryptBackup(a, PASSPHRASE)).toBe(plaintext); + expect(decryptBackup(b, PASSPHRASE)).toBe(plaintext); + }); + + it("requires a non-empty passphrase for encryption", () => { + expect(() => encryptBackup("test", "")).toThrow(/Passphrase/); + expect(() => encryptBackup("test", " ")).toThrow(/Passphrase/); + }); + + it("uses the same controlled error for missing and wrong MDD2 passphrases", () => { + const encrypted = encryptBackup("test data", PASSPHRASE); + expect(() => decryptBackup(encrypted)).toThrow("Backup-Datei konnte nicht entschlüsselt werden"); + expect(() => decryptBackup(encrypted, " ")).toThrow("Backup-Datei konnte nicht entschlüsselt werden"); + expect(() => decryptBackup(encrypted, "wrong test passphrase")).toThrow("Backup-Datei konnte nicht entschlüsselt werden"); + }); + + it("rejects a truncated MDD2 header", () => { + const encrypted = encryptBackup("test data", PASSPHRASE); + expect(() => decryptBackup(encrypted.subarray(0, 47), PASSPHRASE)).toThrow(/zu kurz|ungültig/); + }); + + it("rejects a one-byte-short authenticated ciphertext", () => { + const encrypted = encryptBackup("x", PASSPHRASE); + expect(() => decryptBackup(encrypted.subarray(0, -1), PASSPHRASE)).toThrow("Backup-Datei konnte nicht entschlüsselt werden"); + }); + + it("accepts an authenticated empty ciphertext", () => { + const encrypted = encryptBackup("", PASSPHRASE); + expect(encrypted).toHaveLength(48); + expect(decryptBackup(encrypted, PASSPHRASE)).toBe(""); + }); + + it("rejects a truncated MDD1 backup", () => { + const legacy = Buffer.from("TUREMQcHBwcHBwcHBwcHB7h4ood1DE8Wc+BPgzE6EYdio3HAN/UB1Mru6Fmvtw==", "base64"); + expect(() => decryptBackup(legacy.subarray(0, 31))).toThrow(/zu kurz|ungültig/); + }); it.each([ ["salt", 4], @@ -57,10 +96,10 @@ describe("backup-crypto", () => { ["authentication tag", 32], ["ciphertext", 48] ])("rejects a modified %s", (_part, offset) => { - const encrypted = encryptBackup("test data"); + const encrypted = encryptBackup("test data", PASSPHRASE); const corrupted = Buffer.from(encrypted); corrupted[offset] ^= 0xff; - expect(() => decryptBackup(corrupted)).toThrow(/beschädigt|authentifiziert/); + expect(() => decryptBackup(corrupted, PASSPHRASE)).toThrow("Backup-Datei konnte nicht entschlüsselt werden"); }); it("rejects modified legacy authentication data", () => { @@ -75,7 +114,7 @@ describe("backup-crypto", () => { }); it("throws on wrong magic bytes", () => { - const encrypted = encryptBackup("test data"); + const encrypted = encryptBackup("test data", PASSPHRASE); const wrongMagic = Buffer.from(encrypted); wrongMagic[0] = 0x00; expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/); @@ -87,19 +126,19 @@ describe("backup-crypto", () => { it("handles large payloads", () => { const large = JSON.stringify({ data: "x".repeat(1_000_000) }); - const encrypted = encryptBackup(large); - const decrypted = decryptBackup(encrypted); + const encrypted = encryptBackup(large, PASSPHRASE); + const decrypted = decryptBackup(encrypted, PASSPHRASE); expect(decrypted).toBe(large); }); it("handles unicode content", () => { const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" }); - const encrypted = encryptBackup(unicode); - expect(decryptBackup(encrypted)).toBe(unicode); + const encrypted = encryptBackup(unicode, PASSPHRASE); + expect(decryptBackup(encrypted, PASSPHRASE)).toBe(unicode); }); it("handles empty string round-trip", () => { - const encrypted = encryptBackup(""); - expect(decryptBackup(encrypted)).toBe(""); + const encrypted = encryptBackup("", PASSPHRASE); + expect(decryptBackup(encrypted, PASSPHRASE)).toBe(""); }); }); diff --git a/tests/backup-passphrase-flow.test.tsx b/tests/backup-passphrase-flow.test.tsx new file mode 100644 index 0000000..03b4b05 --- /dev/null +++ b/tests/backup-passphrase-flow.test.tsx @@ -0,0 +1,109 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { decryptBackup, encryptBackup } from "../src/main/backup-crypto"; +import { + LocalBackupApi, + runLocalBackupExport, + runLocalBackupImport, + validateBackupPassphrase +} from "../src/renderer/backup-flow"; +import { BackupPassphraseDialog } from "../src/renderer/ui/BackupPassphraseDialog"; + +function createApi(overrides: Partial = {}): LocalBackupApi { + return { + exportBackup: vi.fn(async () => ({ saved: true })), + selectBackupImport: vi.fn(async () => ({ selected: true, requiresPassphrase: true })), + importBackup: vi.fn(async () => ({ restored: true, relaunch: false, message: "Einstellungen wiederhergestellt" })), + cancelBackupImport: vi.fn(async () => undefined), + ...overrides + }; +} + +describe("local backup passphrase flow", () => { + it("rejects an export confirmation mismatch before IPC", () => { + expect(validateBackupPassphrase("export", "first test phrase", "second test phrase")).toBe("Die Passphrasen stimmen nicht überein"); + }); + + it("cancels export without invoking the backup API", async () => { + const api = createApi(); + const result = await runLocalBackupExport(api, async () => null); + + expect(result).toEqual({ saved: false }); + expect(api.exportBackup).not.toHaveBeenCalled(); + }); + + it("cancels a selected MDD2 import and clears the pending main operation", async () => { + const api = createApi(); + const result = await runLocalBackupImport(api, async () => null); + + expect(result).toEqual({ restored: false, relaunch: false, message: "Abgebrochen" }); + expect(api.cancelBackupImport).toHaveBeenCalledOnce(); + expect(api.importBackup).not.toHaveBeenCalled(); + }); + + it("leaves import untouched when file selection is cancelled", async () => { + const requestPassphrase = vi.fn(async () => "unused"); + const api = createApi({ + selectBackupImport: vi.fn(async () => ({ selected: false, requiresPassphrase: false, message: "Abgebrochen" })) + }); + const result = await runLocalBackupImport(api, requestPassphrase); + + expect(result).toEqual({ restored: false, relaunch: false, message: "Abgebrochen" }); + expect(requestPassphrase).not.toHaveBeenCalled(); + expect(api.importBackup).not.toHaveBeenCalled(); + expect(api.cancelBackupImport).not.toHaveBeenCalled(); + }); + + it("imports MDD1 without requesting a passphrase", async () => { + const requestPassphrase = vi.fn(async () => "unused"); + const api = createApi({ + selectBackupImport: vi.fn(async () => ({ selected: true, requiresPassphrase: false })) + }); + + await runLocalBackupImport(api, requestPassphrase); + + expect(requestPassphrase).not.toHaveBeenCalled(); + expect(api.importBackup).toHaveBeenCalledWith(undefined); + }); + + it("completes an export and import round-trip without returning the passphrase", async () => { + let backup: Buffer | undefined; + let restored = ""; + const api = createApi({ + exportBackup: vi.fn(async (passphrase) => { + backup = encryptBackup("round-trip payload", passphrase); + return { saved: true }; + }), + selectBackupImport: vi.fn(async () => ({ selected: true, requiresPassphrase: true })), + importBackup: vi.fn(async (passphrase) => { + if (!backup) { + throw new Error("Backup missing"); + } + restored = decryptBackup(backup, passphrase); + return { restored: true, relaunch: false, message: "Einstellungen wiederhergestellt" }; + }) + }); + + const exported = await runLocalBackupExport(api, async () => "one-operation test phrase"); + const imported = await runLocalBackupImport(api, async () => "one-operation test phrase"); + + expect(exported).toEqual({ saved: true }); + expect(imported).toEqual({ restored: true, relaunch: false, message: "Einstellungen wiederhergestellt" }); + expect(restored).toBe("round-trip payload"); + expect(JSON.stringify([exported, imported])).not.toContain("one-operation test phrase"); + }); + + it("renders two password fields for export and one for import", () => { + const exportHtml = renderToStaticMarkup( + {}} onSubmit={() => {}} /> + ); + const importHtml = renderToStaticMarkup( + {}} onSubmit={() => {}} /> + ); + + expect(exportHtml.match(/type="password"/g)).toHaveLength(2); + expect(importHtml.match(/type="password"/g)).toHaveLength(1); + expect(exportHtml).toContain("Passphrase bestätigen"); + expect(importHtml).not.toContain("Passphrase bestätigen"); + }); +}); diff --git a/tests/backup-preload.test.ts b/tests/backup-preload.test.ts new file mode 100644 index 0000000..6092aa2 --- /dev/null +++ b/tests/backup-preload.test.ts @@ -0,0 +1,57 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { IPC_CHANNELS } from "../src/shared/ipc"; +import type { ElectronApi } from "../src/shared/preload-api"; + +const electron = vi.hoisted(() => ({ + api: undefined as ElectronApi | undefined, + invoke: vi.fn<(...args: unknown[]) => Promise>(async () => undefined) +})); + +vi.mock("electron", () => ({ + contextBridge: { + exposeInMainWorld: (_name: string, api: ElectronApi) => { + electron.api = api; + } + }, + ipcRenderer: { + invoke: electron.invoke, + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn() + } +})); + +describe("backup preload contract", () => { + beforeAll(async () => { + await import("../src/preload/preload"); + }); + + beforeEach(() => { + electron.invoke.mockClear(); + }); + + it("forwards an export passphrase without adding it to the result contract", async () => { + electron.invoke.mockResolvedValueOnce({ saved: true }); + const result = await electron.api?.exportBackup("one-operation test phrase"); + + expect(electron.invoke).toHaveBeenCalledWith(IPC_CHANNELS.EXPORT_BACKUP, "one-operation test phrase"); + expect(result).toEqual({ saved: true }); + }); + + it("keeps import selection and cancellation passphrase-free", async () => { + electron.invoke.mockResolvedValueOnce({ selected: true, requiresPassphrase: true }); + await electron.api?.selectBackupImport(); + await electron.api?.cancelBackupImport(); + + expect(electron.invoke).toHaveBeenNthCalledWith(1, IPC_CHANNELS.SELECT_BACKUP_IMPORT); + expect(electron.invoke).toHaveBeenNthCalledWith(2, IPC_CHANNELS.CANCEL_BACKUP_IMPORT); + }); + + it("forwards an import passphrase only to the consuming operation", async () => { + electron.invoke.mockResolvedValueOnce({ restored: true, relaunch: false, message: "Einstellungen wiederhergestellt" }); + const result = await electron.api?.importBackup("one-operation test phrase"); + + expect(electron.invoke).toHaveBeenCalledWith(IPC_CHANNELS.IMPORT_BACKUP, "one-operation test phrase"); + expect(result).toEqual({ restored: true, relaunch: false, message: "Einstellungen wiederhergestellt" }); + }); +}); diff --git a/tests/overlay-host.test.tsx b/tests/overlay-host.test.tsx index 9765d5b..498c14e 100644 --- a/tests/overlay-host.test.tsx +++ b/tests/overlay-host.test.tsx @@ -8,6 +8,7 @@ describe("OverlayHost", () => { it("renders every desktop overlay slot exactly once", () => { const slots = { confirm: confirm-slot, + backupPassphrase: backup-passphrase-slot, onlineBackup: backup-slot, diagnostics: diagnostics-slot, deleteConfirmation: delete-slot, diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index 451d4f3..c568bfc 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -171,7 +171,9 @@ export function createVisualElectronApi( restart: async () => {}, quit: async () => {}, exportBackup: async () => ({ saved: true }), + selectBackupImport: async () => ({ selected: true, requiresPassphrase: false }), importBackup: async () => ({ restored: true, relaunch: false, message: "Visual backup importiert" }), + cancelBackupImport: async () => {}, exportOnlineBackup: async () => ({ key: "visual-online-backup-key" }), importOnlineBackup: async () => ({ restored: true, relaunch: false, message: "Visual online backup importiert" }), exportSupportBundle: async () => ({ saved: true, filePath: "C:\\Visual\\Support\\support.zip" }),