fix(security): require passphrases for local backups

Derive every MDD2 key from a non-empty user passphrase and the per-backup scrypt salt while keeping the embedded application material isolated to read-only MDD1 migration imports. Normalize missing, wrong, and authentication-failure results to the same controlled decryption error.

Add the modal-based export confirmation and format-aware import flow across renderer, preload, IPC, and controller boundaries. Clear transient passphrase state on completion or cancellation, retain pending import data only until consumption, and keep passphrases out of results, snapshots, payloads, and logs.

Cover mismatch and cancellation paths, MDD1 passphrase-free migration, preload forwarding, successful UI/crypto round-trips, one-byte-short ciphertext, authenticated empty ciphertext, and truncated legacy envelopes.
This commit is contained in:
Sucukdeluxe
2026-08-11 22:32:58 +02:00
parent 6f94e13cb5
commit 1cb381fa50
17 changed files with 563 additions and 96 deletions
+31 -12
View File
@@ -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<typeof setInterval> | null = null;
let updateQuitTimer: ReturnType<typeof setTimeout> | null = null;
let scheduledStartTimer: ReturnType<typeof setTimeout> | 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.