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
+6 -5
View File
@@ -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<string, unknown>;
try {
const json = decryptBackup(data);
const json = decryptBackup(data, passphrase);
parsed = JSON.parse(json) as Record<string, unknown>;
} catch {
try {
+53 -40
View File
@@ -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);
+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.
+4 -2
View File
@@ -61,8 +61,10 @@ const api: ElectronApi = {
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
quit: (): Promise<void> => 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<void> => 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),
+36 -6
View File
@@ -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<StartConflictPromptState | null>(null);
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
const [backupPassphraseMode, setBackupPassphraseMode] = useState<BackupPassphraseMode | null>(null);
const [onlineBackupDialog, setOnlineBackupDialog] = useState<OnlineBackupDialogState | null>(null);
const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(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<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]);
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(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<string | null> => {
return new Promise((resolve) => {
backupPassphraseResolverRef.current = resolve;
setBackupPassphraseMode(mode);
});
}, []);
const restoreHistoryEntries = useCallback(async (entryIds: string[]): Promise<void> => {
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<void> => {
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<void> => {
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 {
</AppShell>
<OverlayHost
backupPassphrase={backupPassphraseMode ? (
<BackupPassphraseDialog
mode={backupPassphraseMode}
onCancel={() => closeBackupPassphraseDialog(null)}
onSubmit={(passphrase) => closeBackupPassphraseDialog(passphrase)}
/>
) : null}
confirm={confirmPrompt ? (
<Dialog actions={null} danger={confirmPrompt.danger} onClose={() => closeConfirmPrompt(false)} open title={confirmPrompt.title}>
<p style={{ whiteSpace: "pre-line" }}>{confirmPrompt.message}</p>
+66
View File
@@ -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<BackupSelectionResult>;
importBackup: (passphrase?: string) => Promise<BackupImportResult>;
cancelBackupImport: () => Promise<void>;
}
export type BackupPassphraseMode = "export" | "import";
export type BackupPassphraseRequest = (mode: BackupPassphraseMode) => Promise<string | null>;
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<BackupImportResult> {
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);
}
+3
View File
@@ -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"],
+3
View File
@@ -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 (
<div className="md-overlay-host" id="md-overlay-host">
{confirm}
{backupPassphrase}
{onlineBackup}
{diagnostics}
{deleteConfirmation}
+40
View File
@@ -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;
@@ -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<HTMLFormElement>): void => {
event.preventDefault();
const validationError = validateBackupPassphrase(mode, passphrase, confirmation);
if (validationError) {
setError(validationError);
return;
}
const submittedPassphrase = passphrase;
clear();
onSubmit(submittedPassphrase);
};
return (
<Dialog actions={null} className="backup-passphrase-modal" onClose={cancel} open title={mode === "export" ? "Sicherung schützen" : "Sicherung entsperren"}>
<p>{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."}</p>
<form className="backup-passphrase-form" onSubmit={submit}>
<div className="backup-passphrase-fields">
<label>
Passphrase
<input
aria-label="Backup-Passphrase"
autoComplete="off"
autoFocus
onChange={(event) => { setPassphrase(event.target.value); setError(""); }}
spellCheck={false}
type="password"
value={passphrase}
/>
</label>
{mode === "export" && (
<label>
Passphrase bestätigen
<input
aria-label="Backup-Passphrase bestätigen"
autoComplete="off"
onChange={(event) => { setConfirmation(event.target.value); setError(""); }}
spellCheck={false}
type="password"
value={confirmation}
/>
</label>
)}
</div>
{error && <div className="backup-passphrase-error" role="alert">{error}</div>}
<div className="modal-actions">
<button className="btn" onClick={cancel} type="button">Abbrechen</button>
<button className="btn primary" type="submit">{mode === "export" ? "Sicherung exportieren" : "Sicherung importieren"}</button>
</div>
</form>
</Dialog>
);
}
+3 -1
View File
@@ -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",
+4 -2
View File
@@ -58,8 +58,10 @@ export interface ElectronApi {
resetDownloadStats: () => Promise<void>;
restart: () => Promise<void>;
quit: () => Promise<void>;
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<void>;
exportOnlineBackup: () => Promise<{ key: string }>;
importOnlineBackup: (key: string) => Promise<{ restored: boolean; relaunch: false; message: string }>;
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
+67 -28
View File
@@ -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("");
});
});
+109
View File
@@ -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> = {}): 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(
<BackupPassphraseDialog mode="export" onCancel={() => {}} onSubmit={() => {}} />
);
const importHtml = renderToStaticMarkup(
<BackupPassphraseDialog mode="import" onCancel={() => {}} 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");
});
});
+57
View File
@@ -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<unknown>>(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" });
});
});
+1
View File
@@ -8,6 +8,7 @@ describe("OverlayHost", () => {
it("renders every desktop overlay slot exactly once", () => {
const slots = {
confirm: <span>confirm-slot</span>,
backupPassphrase: <span>backup-passphrase-slot</span>,
onlineBackup: <span>backup-slot</span>,
diagnostics: <span>diagnostics-slot</span>,
deleteConfirmation: <span>delete-slot</span>,
+2
View File
@@ -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" }),