From 0edd8f6be523ab9d81ad30ee047c8c7017fca784 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 7 Mar 2026 16:39:19 +0100 Subject: [PATCH] Redesign backup system: AES-256-GCM encrypted .mdd format - Replace plaintext JSON export with encrypted binary format (JDownloader 2 style) - Fixed app-internal key, works on any machine without password - Export now includes ALL credentials (no more ***-masking), session AND history - Add debridLinkApiKeys, linkSnappy credentials to sensitive keys list - Backward-compatible import: auto-detects legacy JSON backups - File extension changed from .json to .mdd - MDD1 magic bytes + random IV + GCM auth tag for integrity Co-Authored-By: Claude Opus 4.6 --- src/main/app-controller.ts | 65 ++++++++++++++++--------- src/main/backup-crypto.ts | 94 +++++++++++++++---------------------- src/main/main.ts | 15 +++--- tests/backup-crypto.test.ts | 86 +++++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 83 deletions(-) create mode 100644 tests/backup-crypto.test.ts diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 2bf7db5..46a7ffe 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -30,9 +30,10 @@ import { BestDebridWebFallback } from "./bestdebrid-web"; import { RealDebridWebFallback } from "./realdebrid-web"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { MegaWebFallback } from "./mega-web-fallback"; -import { addHistoryEntry, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage"; +import { addHistoryEntry, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveHistory, saveSession, saveSettings } from "./storage"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { startDebugServer, stopDebugServer } from "./debug-server"; +import { encryptBackup, decryptBackup } from "./backup-crypto"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -376,31 +377,48 @@ export class AppController { return this.manager.getSessionStats(); } - public exportBackup(): string { + public exportBackup(): Buffer { const settings = { ...this.settings }; - const SENSITIVE_KEYS: (keyof AppSettings)[] = ["token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey"]; - for (const key of SENSITIVE_KEYS) { - const val = settings[key]; - if (typeof val === "string" && val.length > 0) { - (settings as Record)[key] = `***${val.slice(-4)}`; - } - } const session = this.manager.getSession(); - return JSON.stringify({ version: 1, settings, session }, null, 2); + const history = loadHistory(this.storagePaths); + const payload = JSON.stringify({ + version: 2, + appVersion: APP_VERSION, + exportedAt: new Date().toISOString(), + settings, + session, + history + }); + return encryptBackup(payload); } - public importBackup(json: string): { restored: boolean; message: string } { + public importBackup(data: Buffer): { restored: boolean; message: string } { let parsed: Record; try { + // Try encrypted MDD format first + const json = decryptBackup(data); parsed = JSON.parse(json) as Record; } catch { - return { restored: false, message: "Ungültiges JSON" }; + // Fallback: try legacy plaintext JSON (old backups) + try { + const json = data.toString("utf8"); + parsed = JSON.parse(json) as Record; + } catch { + return { restored: false, message: "Backup-Datei konnte nicht entschlüsselt werden" }; + } } if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) { return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" }; } + + // Restore settings — ALL credentials are included (no more masking) const importedSettings = parsed.settings as AppSettings; - const SENSITIVE_KEYS: (keyof AppSettings)[] = ["token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey"]; + // Legacy backup compatibility: if credentials were masked with ***, keep current values + const SENSITIVE_KEYS: (keyof AppSettings)[] = [ + "token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", + "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey", + "debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword" + ]; for (const key of SENSITIVE_KEYS) { const val = (importedSettings as Record)[key]; if (typeof val === "string" && val.startsWith("***")) { @@ -411,24 +429,29 @@ export class AppController { this.settings = restoredSettings; saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); - // Full stop including extraction abort — the old session is being replaced, - // so no extraction tasks from it should keep running. + + // Full stop including extraction abort this.manager.stop(); this.manager.abortAllPostProcessing(); - // Cancel any deferred persist timer and queued async writes so the old - // in-memory session does not overwrite the restored session file on disk. this.manager.clearPersistTimer(); cancelPendingAsyncSaves(); + + // Restore session const restoredSession = normalizeLoadedSessionTransientFields( normalizeLoadedSession(parsed.session) ); saveSession(this.storagePaths, restoredSession); - // Prevent prepareForShutdown from overwriting the restored session file - // with the old in-memory session when the app quits after backup restore. + + // Restore history (if present in backup) + if (Array.isArray(parsed.history) && parsed.history.length > 0) { + saveHistory(this.storagePaths, parsed.history as HistoryEntry[]); + logger.info(`Backup: ${(parsed.history as HistoryEntry[]).length} History-Einträge wiederhergestellt`); + } + + // Prevent prepareForShutdown from overwriting the restored data this.manager.skipShutdownPersist = true; - // Block all persistence (including persistSoon from any IPC operations - // the user might trigger before restarting) to protect the restored backup. this.manager.blockAllPersistence = true; + logger.info("Backup wiederhergestellt (verschlüsseltes Format)"); return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." }; } diff --git a/src/main/backup-crypto.ts b/src/main/backup-crypto.ts index 944598e..64bc050 100644 --- a/src/main/backup-crypto.ts +++ b/src/main/backup-crypto.ts @@ -1,66 +1,50 @@ import crypto from "node:crypto"; -export const SENSITIVE_KEYS = [ - "token", - "megaLogin", - "megaPassword", - "bestToken", - "allDebridToken", - "archivePasswordList" -] as const; +// Fixed app key — like JDownloader 2: deterministic, works on any machine. +// Not meant to protect against reverse-engineering, just prevents casual +// plaintext snooping when someone opens the backup file. +const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026"; +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; // 96-bit IV for GCM +const AUTH_TAG_LENGTH = 16; +const MAGIC = Buffer.from("MDD1"); // file signature -export type SensitiveKey = (typeof SENSITIVE_KEYS)[number]; - -export interface EncryptedCredentials { - salt: string; - iv: string; - tag: string; - data: string; +function deriveKey(): Buffer { + return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest(); } -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); +/** + * Encrypt a UTF-8 string into an MDD backup buffer. + * Format: MAGIC(4) | IV(12) | AUTH_TAG(16) | CIPHERTEXT(…) + */ +export function encryptBackup(plaintext: string): Buffer { + const key = deriveKey(); 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 cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); 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") - }; + const authTag = cipher.getAuthTag(); + return Buffer.concat([MAGIC, iv, authTag, encrypted]); } -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); +/** + * Decrypt an MDD backup buffer back to a UTF-8 string. + * Throws on invalid/corrupted data. + */ +export function decryptBackup(data: Buffer): string { + if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) { + throw new Error("Backup-Datei zu kurz oder ungültig"); + } + const magic = data.subarray(0, MAGIC.length); + if (!magic.equals(MAGIC)) { + throw new Error("Keine gültige MDD-Backup-Datei (falsche Signatur)"); + } + const iv = data.subarray(MAGIC.length, MAGIC.length + IV_LENGTH); + const authTag = data.subarray(MAGIC.length + IV_LENGTH, MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH); + const ciphertext = data.subarray(MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH); - 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; + const key = deriveKey(); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return decrypted.toString("utf8"); } diff --git a/src/main/main.ts b/src/main/main.ts index 5bf958c..1d67b10 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -476,15 +476,15 @@ function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => { const options = { - defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.json`, - filters: [{ name: "Backup", extensions: ["json"] }] + defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.mdd`, + filters: [{ name: "MDD Backup", extensions: ["mdd"] }] }; const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); if (result.canceled || !result.filePath) { return { saved: false }; } - const json = controller.exportBackup(); - await fs.promises.writeFile(result.filePath, json, "utf8"); + const encrypted = controller.exportBackup(); + await fs.promises.writeFile(result.filePath, encrypted); return { saved: true }; }); @@ -535,7 +535,8 @@ function registerIpcHandlers(): void { const options = { properties: ["openFile"] as Array<"openFile">, filters: [ - { name: "Backup", extensions: ["json"] }, + { name: "MDD Backup", extensions: ["mdd"] }, + { name: "Legacy Backup (JSON)", extensions: ["json"] }, { name: "Alle Dateien", extensions: ["*"] } ] }; @@ -549,8 +550,8 @@ function registerIpcHandlers(): void { 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 json = await fs.promises.readFile(filePath, "utf8"); - return controller.importBackup(json); + const data = await fs.promises.readFile(filePath); + return controller.importBackup(data); }); controller.onState = (snapshot) => { diff --git a/tests/backup-crypto.test.ts b/tests/backup-crypto.test.ts new file mode 100644 index 0000000..8f8ca9c --- /dev/null +++ b/tests/backup-crypto.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { encryptBackup, decryptBackup } from "../src/main/backup-crypto"; + +describe("backup-crypto", () => { + it("encrypts and decrypts a round-trip correctly", () => { + const original = JSON.stringify({ + version: 2, + settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" }, + session: { packages: {}, items: {} }, + history: [{ id: "h1", name: "Test" }] + }); + + const encrypted = encryptBackup(original); + const decrypted = decryptBackup(encrypted); + expect(decrypted).toBe(original); + }); + + it("produces binary output that is not plaintext readable", () => { + const secret = "super-secret-token-12345"; + const plaintext = JSON.stringify({ settings: { token: secret } }); + const encrypted = encryptBackup(plaintext); + + // The encrypted buffer should NOT contain the secret in plaintext + expect(encrypted.toString("utf8")).not.toContain(secret); + expect(encrypted.toString("latin1")).not.toContain(secret); + }); + + it("starts with the MDD1 magic bytes", () => { + const encrypted = encryptBackup("test"); + expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1"); + }); + + it("produces different ciphertext for the same input (random IV)", () => { + const plaintext = "same input data"; + const a = encryptBackup(plaintext); + const b = encryptBackup(plaintext); + // IVs are different, so full buffers must differ + expect(a.equals(b)).toBe(false); + // But both decrypt to the same plaintext + expect(decryptBackup(a)).toBe(plaintext); + expect(decryptBackup(b)).toBe(plaintext); + }); + + it("throws on truncated data", () => { + const encrypted = encryptBackup("test data"); + const truncated = encrypted.subarray(0, 10); + expect(() => decryptBackup(truncated)).toThrow(); + }); + + it("throws on corrupted ciphertext", () => { + const encrypted = encryptBackup("test data"); + // Flip a byte in the ciphertext area + const corrupted = Buffer.from(encrypted); + corrupted[corrupted.length - 1] ^= 0xff; + expect(() => decryptBackup(corrupted)).toThrow(); + }); + + it("throws on wrong magic bytes", () => { + const encrypted = encryptBackup("test data"); + const wrongMagic = Buffer.from(encrypted); + wrongMagic[0] = 0x00; + expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/); + }); + + it("throws on empty buffer", () => { + expect(() => decryptBackup(Buffer.alloc(0))).toThrow(); + }); + + it("handles large payloads", () => { + const large = JSON.stringify({ data: "x".repeat(1_000_000) }); + const encrypted = encryptBackup(large); + const decrypted = decryptBackup(encrypted); + 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); + }); + + it("handles empty string round-trip", () => { + const encrypted = encryptBackup(""); + expect(decryptBackup(encrypted)).toBe(""); + }); +});