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 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-07 16:39:19 +01:00
parent ca534196b8
commit 0edd8f6be5
4 changed files with 177 additions and 83 deletions

View File

@ -30,9 +30,10 @@ import { BestDebridWebFallback } from "./bestdebrid-web";
import { RealDebridWebFallback } from "./realdebrid-web"; import { RealDebridWebFallback } from "./realdebrid-web";
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
import { MegaWebFallback } from "./mega-web-fallback"; 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 { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
import { startDebugServer, stopDebugServer } from "./debug-server"; import { startDebugServer, stopDebugServer } from "./debug-server";
import { encryptBackup, decryptBackup } 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);
@ -376,31 +377,48 @@ export class AppController {
return this.manager.getSessionStats(); return this.manager.getSessionStats();
} }
public exportBackup(): string { public exportBackup(): Buffer {
const settings = { ...this.settings }; 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<string, unknown>)[key] = `***${val.slice(-4)}`;
}
}
const session = this.manager.getSession(); 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<string, unknown>; let parsed: Record<string, unknown>;
try { try {
// Try encrypted MDD format first
const json = decryptBackup(data);
parsed = JSON.parse(json) as Record<string, unknown>; parsed = JSON.parse(json) as Record<string, unknown>;
} catch { } 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<string, unknown>;
} catch {
return { restored: false, message: "Backup-Datei konnte nicht entschlüsselt werden" };
}
} }
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)" };
} }
// Restore settings — ALL credentials are included (no more masking)
const importedSettings = parsed.settings as AppSettings; 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) { for (const key of SENSITIVE_KEYS) {
const val = (importedSettings as Record<string, unknown>)[key]; const val = (importedSettings as Record<string, unknown>)[key];
if (typeof val === "string" && val.startsWith("***")) { if (typeof val === "string" && val.startsWith("***")) {
@ -411,24 +429,29 @@ export class AppController {
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);
// 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.stop();
this.manager.abortAllPostProcessing(); 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(); this.manager.clearPersistTimer();
cancelPendingAsyncSaves(); cancelPendingAsyncSaves();
// Restore session
const restoredSession = normalizeLoadedSessionTransientFields( const restoredSession = normalizeLoadedSessionTransientFields(
normalizeLoadedSession(parsed.session) normalizeLoadedSession(parsed.session)
); );
saveSession(this.storagePaths, restoredSession); 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; 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; this.manager.blockAllPersistence = true;
logger.info("Backup wiederhergestellt (verschlüsseltes Format)");
return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." }; return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." };
} }

View File

@ -1,66 +1,50 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
export const SENSITIVE_KEYS = [ // Fixed app key — like JDownloader 2: deterministic, works on any machine.
"token", // Not meant to protect against reverse-engineering, just prevents casual
"megaLogin", // plaintext snooping when someone opens the backup file.
"megaPassword", const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
"bestToken", const ALGORITHM = "aes-256-gcm";
"allDebridToken", const IV_LENGTH = 12; // 96-bit IV for GCM
"archivePasswordList" const AUTH_TAG_LENGTH = 16;
] as const; const MAGIC = Buffer.from("MDD1"); // file signature
export type SensitiveKey = (typeof SENSITIVE_KEYS)[number]; function deriveKey(): Buffer {
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
export interface EncryptedCredentials {
salt: string;
iv: string;
tag: string;
data: string;
} }
const PBKDF2_ITERATIONS = 100_000; /**
const KEY_LENGTH = 32; // 256 bit * Encrypt a UTF-8 string into an MDD backup buffer.
const IV_LENGTH = 12; // 96 bit for GCM * Format: MAGIC(4) | IV(12) | AUTH_TAG(16) | CIPHERTEXT()
const SALT_LENGTH = 16; */
export function encryptBackup(plaintext: string): Buffer {
function deriveKey(username: string, salt: Buffer): Buffer { const key = deriveKey();
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 iv = crypto.randomBytes(IV_LENGTH);
const key = deriveKey(username, salt); const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
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 encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag(); const authTag = cipher.getAuthTag();
return Buffer.concat([MAGIC, iv, authTag, encrypted]);
return {
salt: salt.toString("hex"),
iv: iv.toString("hex"),
tag: tag.toString("hex"),
data: encrypted.toString("hex")
};
} }
export function decryptCredentials( /**
encrypted: EncryptedCredentials, * Decrypt an MDD backup buffer back to a UTF-8 string.
username: string * Throws on invalid/corrupted data.
): Record<string, string> { */
const salt = Buffer.from(encrypted.salt, "hex"); export function decryptBackup(data: Buffer): string {
const iv = Buffer.from(encrypted.iv, "hex"); if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) {
const tag = Buffer.from(encrypted.tag, "hex"); throw new Error("Backup-Datei zu kurz oder ungültig");
const data = Buffer.from(encrypted.data, "hex"); }
const key = deriveKey(username, salt); 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); const key = deriveKey();
decipher.setAuthTag(tag); const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
const decrypted = Buffer.concat([decipher.update(data), decipher.final()]); decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return JSON.parse(decrypted.toString("utf8")) as Record<string, string>; return decrypted.toString("utf8");
} }

View File

@ -476,15 +476,15 @@ function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => { ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
const options = { const options = {
defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.json`, defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.mdd`,
filters: [{ name: "Backup", extensions: ["json"] }] filters: [{ name: "MDD Backup", extensions: ["mdd"] }]
}; };
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
if (result.canceled || !result.filePath) { if (result.canceled || !result.filePath) {
return { saved: false }; return { saved: false };
} }
const json = controller.exportBackup(); const encrypted = controller.exportBackup();
await fs.promises.writeFile(result.filePath, json, "utf8"); await fs.promises.writeFile(result.filePath, encrypted);
return { saved: true }; return { saved: true };
}); });
@ -535,7 +535,8 @@ function registerIpcHandlers(): void {
const options = { const options = {
properties: ["openFile"] as Array<"openFile">, properties: ["openFile"] as Array<"openFile">,
filters: [ filters: [
{ name: "Backup", extensions: ["json"] }, { name: "MDD Backup", extensions: ["mdd"] },
{ name: "Legacy Backup (JSON)", extensions: ["json"] },
{ name: "Alle Dateien", extensions: ["*"] } { name: "Alle Dateien", extensions: ["*"] }
] ]
}; };
@ -549,8 +550,8 @@ function registerIpcHandlers(): void {
if (stat.size > BACKUP_MAX_BYTES) { 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)` }; 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"); const data = await fs.promises.readFile(filePath);
return controller.importBackup(json); return controller.importBackup(data);
}); });
controller.onState = (snapshot) => { controller.onState = (snapshot) => {

View File

@ -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("");
});
});