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:
parent
9ac557b0a8
commit
ac479bb023
@ -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",
|
||||
|
||||
@ -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<AppSettings>): Partial<AppSettings> {
|
||||
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<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();
|
||||
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<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;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings);
|
||||
|
||||
66
src/main/backup-crypto.ts
Normal file
66
src/main/backup-crypto.ts
Normal 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>;
|
||||
}
|
||||
@ -254,9 +254,31 @@ function registerIpcHandlers(): void {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => {
|
||||
const validated = validatePlainObject(partial ?? {}, "partial");
|
||||
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, async (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => {
|
||||
const validated = validatePlainObject(partial ?? {}, "partial") 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();
|
||||
updateTray();
|
||||
return result;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user