Add backup encryption (AES-256-GCM) and directory existence check

- Encrypt sensitive credentials (tokens, passwords) in backup exports
  using AES-256-GCM with PBKDF2 key derivation from OS username
- Backup format v2 with backwards-compatible v1 import
- Show dialog to create non-existent directories when changing
  outputDir, extractDir, or mkvLibraryDir settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-03 13:47:56 +01:00
parent 9ac557b0a8
commit ac479bb023
4 changed files with 125 additions and 7 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.5.49", "version": "1.5.50",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -1,3 +1,4 @@
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { app } from "electron"; import { app } from "electron";
import { import {
@ -23,6 +24,7 @@ import { MegaWebFallback } from "./mega-web-fallback";
import { addHistoryEntry, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage"; import { addHistoryEntry, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage";
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
import { startDebugServer, stopDebugServer } from "./debug-server"; import { startDebugServer, stopDebugServer } from "./debug-server";
import { decryptCredentials, encryptCredentials, SENSITIVE_KEYS } from "./backup-crypto";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> { function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@ -253,9 +255,16 @@ export class AppController {
} }
public exportBackup(): string { public exportBackup(): string {
const settings = this.settings; const settingsCopy = { ...this.settings } as Record<string, unknown>;
const sensitiveFields: Record<string, string> = {};
for (const key of SENSITIVE_KEYS) {
sensitiveFields[key] = String(settingsCopy[key] ?? "");
delete settingsCopy[key];
}
const username = os.userInfo().username;
const credentials = encryptCredentials(sensitiveFields, username);
const session = this.manager.getSession(); const session = this.manager.getSession();
return JSON.stringify({ version: 1, settings, session }, null, 2); return JSON.stringify({ version: 2, settings: settingsCopy, credentials, session }, null, 2);
} }
public importBackup(json: string): { restored: boolean; message: string } { public importBackup(json: string): { restored: boolean; message: string } {
@ -268,7 +277,28 @@ export class AppController {
if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) { if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) {
return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" }; return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" };
} }
const restoredSettings = normalizeSettings(parsed.settings as AppSettings);
const version = typeof parsed.version === "number" ? parsed.version : 1;
let settingsObj = parsed.settings as Record<string, unknown>;
if (version >= 2) {
const creds = parsed.credentials as { salt: string; iv: string; tag: string; data: string } | undefined;
if (!creds || !creds.salt || !creds.iv || !creds.tag || !creds.data) {
return { restored: false, message: "Backup v2: Verschlüsselte Zugangsdaten fehlen" };
}
try {
const username = os.userInfo().username;
const decrypted = decryptCredentials(creds, username);
settingsObj = { ...settingsObj, ...decrypted };
} catch {
return {
restored: false,
message: "Entschlüsselung fehlgeschlagen. Das Backup wurde mit einem anderen Benutzer erstellt."
};
}
}
const restoredSettings = normalizeSettings(settingsObj as AppSettings);
this.settings = restoredSettings; this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings); this.manager.setSettings(this.settings);

66
src/main/backup-crypto.ts Normal file
View File

@ -0,0 +1,66 @@
import crypto from "node:crypto";
export const SENSITIVE_KEYS = [
"token",
"megaLogin",
"megaPassword",
"bestToken",
"allDebridToken",
"archivePasswordList"
] as const;
export type SensitiveKey = (typeof SENSITIVE_KEYS)[number];
export interface EncryptedCredentials {
salt: string;
iv: string;
tag: string;
data: string;
}
const PBKDF2_ITERATIONS = 100_000;
const KEY_LENGTH = 32; // 256 bit
const IV_LENGTH = 12; // 96 bit for GCM
const SALT_LENGTH = 16;
function deriveKey(username: string, salt: Buffer): Buffer {
return crypto.pbkdf2Sync(username, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256");
}
export function encryptCredentials(
fields: Record<string, string>,
username: string
): EncryptedCredentials {
const salt = crypto.randomBytes(SALT_LENGTH);
const iv = crypto.randomBytes(IV_LENGTH);
const key = deriveKey(username, salt);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const plaintext = JSON.stringify(fields);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return {
salt: salt.toString("hex"),
iv: iv.toString("hex"),
tag: tag.toString("hex"),
data: encrypted.toString("hex")
};
}
export function decryptCredentials(
encrypted: EncryptedCredentials,
username: string
): Record<string, string> {
const salt = Buffer.from(encrypted.salt, "hex");
const iv = Buffer.from(encrypted.iv, "hex");
const tag = Buffer.from(encrypted.tag, "hex");
const data = Buffer.from(encrypted.data, "hex");
const key = deriveKey(username, salt);
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
return JSON.parse(decrypted.toString("utf8")) as Record<string, string>;
}

View File

@ -254,9 +254,31 @@ function registerIpcHandlers(): void {
return false; return false;
} }
}); });
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => { ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, async (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => {
const validated = validatePlainObject(partial ?? {}, "partial"); const validated = validatePlainObject(partial ?? {}, "partial") as Partial<AppSettings>;
const result = controller.updateSettings(validated as Partial<AppSettings>); const oldSettings = controller.getSettings();
const dirKeys = ["outputDir", "extractDir", "mkvLibraryDir"] as const;
for (const key of dirKeys) {
const newVal = validated[key];
if (typeof newVal === "string" && newVal.trim() && newVal !== oldSettings[key]) {
if (!fs.existsSync(newVal)) {
const msgOpts = {
type: "question" as const,
buttons: ["Ja", "Nein"],
defaultId: 0,
title: "Ordner erstellen?",
message: `Der Ordner existiert nicht:\n${newVal}\n\nSoll er erstellt werden?`
};
const { response } = mainWindow
? await dialog.showMessageBox(mainWindow, msgOpts)
: await dialog.showMessageBox(msgOpts);
if (response === 0) {
fs.mkdirSync(newVal, { recursive: true });
}
}
}
}
const result = controller.updateSettings(validated);
updateClipboardWatcher(); updateClipboardWatcher();
updateTray(); updateTray();
return result; return result;