From ac479bb0232969653b37c98cdca596abf52f706d Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Tue, 3 Mar 2026 13:47:56 +0100 Subject: [PATCH] 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 --- package.json | 2 +- src/main/app-controller.ts | 36 +++++++++++++++++++-- src/main/backup-crypto.ts | 66 ++++++++++++++++++++++++++++++++++++++ src/main/main.ts | 28 ++++++++++++++-- 4 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 src/main/backup-crypto.ts diff --git a/package.json b/package.json index b2f964e..66c84e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.5.49", + "version": "1.5.50", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index dc51d2d..4770edb 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -1,3 +1,4 @@ +import os from "node:os"; import path from "node:path"; import { app } from "electron"; 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 { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { startDebugServer, stopDebugServer } from "./debug-server"; +import { decryptCredentials, encryptCredentials, SENSITIVE_KEYS } from "./backup-crypto"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -253,9 +255,16 @@ export class AppController { } public exportBackup(): string { - const settings = this.settings; + const settingsCopy = { ...this.settings } as Record; + const sensitiveFields: Record = {}; + 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(); - 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 } { @@ -268,7 +277,28 @@ export class AppController { if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) { 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; + + 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; saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); diff --git a/src/main/backup-crypto.ts b/src/main/backup-crypto.ts new file mode 100644 index 0000000..944598e --- /dev/null +++ b/src/main/backup-crypto.ts @@ -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, + 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 { + 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; +} diff --git a/src/main/main.ts b/src/main/main.ts index ec42952..ea2a021 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -254,9 +254,31 @@ function registerIpcHandlers(): void { return false; } }); - ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial) => { - const validated = validatePlainObject(partial ?? {}, "partial"); - const result = controller.updateSettings(validated as Partial); + ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, async (_event: IpcMainInvokeEvent, partial: Partial) => { + const validated = validatePlainObject(partial ?? {}, "partial") as Partial; + 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(); updateTray(); return result;