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:
parent
ca534196b8
commit
0edd8f6be5
@ -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." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
86
tests/backup-crypto.test.ts
Normal file
86
tests/backup-crypto.test.ts
Normal 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user