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",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.5.49",
|
"version": "1.5.50",
|
||||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import {
|
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 { addHistoryEntry, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeSettings, removeHistoryEntry, 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 { decryptCredentials, encryptCredentials, SENSITIVE_KEYS } 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);
|
||||||
@ -253,9 +255,16 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public exportBackup(): string {
|
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();
|
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 } {
|
public importBackup(json: string): { restored: boolean; message: string } {
|
||||||
@ -268,7 +277,28 @@ export class AppController {
|
|||||||
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)" };
|
||||||
}
|
}
|
||||||
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;
|
this.settings = restoredSettings;
|
||||||
saveSettings(this.storagePaths, this.settings);
|
saveSettings(this.storagePaths, this.settings);
|
||||||
this.manager.setSettings(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;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => {
|
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, async (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => {
|
||||||
const validated = validatePlainObject(partial ?? {}, "partial");
|
const validated = validatePlainObject(partial ?? {}, "partial") as Partial<AppSettings>;
|
||||||
const result = controller.updateSettings(validated 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();
|
updateClipboardWatcher();
|
||||||
updateTray();
|
updateTray();
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user