fix(security): require passphrases for local backups
Derive every MDD2 key from a non-empty user passphrase and the per-backup scrypt salt while keeping the embedded application material isolated to read-only MDD1 migration imports. Normalize missing, wrong, and authentication-failure results to the same controlled decryption error. Add the modal-based export confirmation and format-aware import flow across renderer, preload, IPC, and controller boundaries. Clear transient passphrase state on completion or cancellation, retain pending import data only until consumption, and keep passphrases out of results, snapshots, payloads, and logs. Cover mismatch and cancellation paths, MDD1 passphrase-free migration, preload forwarding, successful UI/crypto round-trips, one-byte-short ciphertext, authenticated empty ciphertext, and truncated legacy envelopes.
This commit is contained in:
@@ -752,7 +752,7 @@ public async checkDebridAccounts(settingsOverride?: AppSettings, persistValidOve
|
||||
this.audit("INFO", "Download-Statistik zurückgesetzt");
|
||||
}
|
||||
|
||||
public exportBackup(): Buffer {
|
||||
public exportBackup(passphrase: string): Buffer {
|
||||
let remoteDiagnostics: BackupRemoteDiagnostics | undefined;
|
||||
if (Boolean(this.settings.backupIncludeRemoteDiagnostics)) {
|
||||
const status = getDebugServerRuntimeStatus();
|
||||
@@ -770,13 +770,14 @@ public async checkDebridAccounts(settingsOverride?: AppSettings, persistValidOve
|
||||
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()),
|
||||
remoteDiagnostics
|
||||
});
|
||||
this.audit("INFO", "Backup exportiert", {
|
||||
const encrypted = encryptBackup(JSON.stringify(payloadObj), passphrase);
|
||||
this.audit("INFO", "Backup exportiert", {
|
||||
kind: payloadObj.kind,
|
||||
historyEntries: payloadObj.history ? payloadObj.history.length : 0,
|
||||
sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0,
|
||||
sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0
|
||||
});
|
||||
return encryptBackup(JSON.stringify(payloadObj));
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
public async exportOnlineBackup(): Promise<{ key: string }> {
|
||||
@@ -812,10 +813,10 @@ public async checkDebridAccounts(settingsOverride?: AppSettings, persistValidOve
|
||||
return getSupportBundleDefaultFileName();
|
||||
}
|
||||
|
||||
public importBackup(data: Buffer): { restored: boolean; relaunch: boolean; message: string } {
|
||||
public importBackup(data: Buffer, passphrase?: string): { restored: boolean; relaunch: boolean; message: string } {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
const json = decryptBackup(data);
|
||||
const json = decryptBackup(data, passphrase);
|
||||
parsed = JSON.parse(json) as Record<string, unknown>;
|
||||
} catch {
|
||||
try {
|
||||
|
||||
+53
-40
@@ -1,6 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
||||
const LEGACY_APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const KEY_LENGTH = 32;
|
||||
const SALT_LENGTH = 16;
|
||||
@@ -9,24 +9,26 @@ const AUTH_TAG_LENGTH = 16;
|
||||
const PREFIX = Buffer.from("MDD");
|
||||
const LEGACY_MAGIC = Buffer.from("MDD1");
|
||||
const MAGIC = Buffer.from("MDD2");
|
||||
const LEGACY_HEADER_LENGTH = LEGACY_MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH;
|
||||
const HEADER_LENGTH = MAGIC.length + SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
|
||||
|
||||
function deriveLegacyKey(): Buffer {
|
||||
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
|
||||
}
|
||||
|
||||
function deriveKey(salt: Buffer): Buffer {
|
||||
return crypto.scryptSync(APP_KEY_MATERIAL, salt, KEY_LENGTH);
|
||||
}
|
||||
const LEGACY_HEADER_LENGTH = LEGACY_MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH;
|
||||
const HEADER_LENGTH = MAGIC.length + SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
|
||||
const DECRYPTION_ERROR = "Backup-Datei konnte nicht entschlüsselt werden";
|
||||
|
||||
function deriveLegacyKey(): Buffer {
|
||||
return crypto.createHash("sha256").update(LEGACY_APP_KEY_MATERIAL).digest();
|
||||
}
|
||||
|
||||
function deriveKey(passphrase: string, salt: Buffer): Buffer {
|
||||
return crypto.scryptSync(passphrase, salt, KEY_LENGTH);
|
||||
}
|
||||
|
||||
function decryptAuthenticated(
|
||||
ciphertext: Buffer,
|
||||
key: Buffer,
|
||||
iv: Buffer,
|
||||
authTag: Buffer,
|
||||
authenticatedData?: Buffer
|
||||
): string {
|
||||
key: Buffer,
|
||||
iv: Buffer,
|
||||
authTag: Buffer,
|
||||
authenticatedData?: Buffer,
|
||||
errorMessage = "Backup-Datei ist beschädigt oder konnte nicht authentifiziert werden"
|
||||
): string {
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
if (authenticatedData) {
|
||||
@@ -34,8 +36,8 @@ function decryptAuthenticated(
|
||||
}
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||
} catch {
|
||||
throw new Error("Backup-Datei ist beschädigt oder konnte nicht authentifiziert werden");
|
||||
} catch {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,10 +56,13 @@ function decryptLegacyBackup(data: Buffer): string {
|
||||
);
|
||||
}
|
||||
|
||||
function decryptCurrentBackup(data: Buffer): string {
|
||||
if (data.length < HEADER_LENGTH) {
|
||||
throw new Error("Backup-Datei zu kurz oder ungültig");
|
||||
}
|
||||
function decryptCurrentBackup(data: Buffer, passphrase?: string): string {
|
||||
if (data.length < HEADER_LENGTH) {
|
||||
throw new Error("Backup-Datei zu kurz oder ungültig");
|
||||
}
|
||||
if (typeof passphrase !== "string" || passphrase.trim().length === 0) {
|
||||
throw new Error(DECRYPTION_ERROR);
|
||||
}
|
||||
const saltStart = MAGIC.length;
|
||||
const ivStart = saltStart + SALT_LENGTH;
|
||||
const authTagStart = ivStart + IV_LENGTH;
|
||||
@@ -66,30 +71,38 @@ function decryptCurrentBackup(data: Buffer): string {
|
||||
const iv = data.subarray(ivStart, authTagStart);
|
||||
return decryptAuthenticated(
|
||||
data.subarray(ciphertextStart),
|
||||
deriveKey(salt),
|
||||
iv,
|
||||
data.subarray(authTagStart, ciphertextStart),
|
||||
data.subarray(0, authTagStart)
|
||||
);
|
||||
}
|
||||
|
||||
export function encryptBackup(plaintext: string): Buffer {
|
||||
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const key = deriveKey(salt);
|
||||
deriveKey(passphrase, salt),
|
||||
iv,
|
||||
data.subarray(authTagStart, ciphertextStart),
|
||||
data.subarray(0, authTagStart),
|
||||
DECRYPTION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
export function encryptBackup(plaintext: string, passphrase: string): Buffer {
|
||||
if (typeof passphrase !== "string" || passphrase.trim().length === 0) {
|
||||
throw new Error("Backup-Passphrase erforderlich");
|
||||
}
|
||||
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const key = deriveKey(passphrase, salt);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
cipher.setAAD(Buffer.concat([MAGIC, salt, iv]));
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
return Buffer.concat([MAGIC, salt, iv, cipher.getAuthTag(), encrypted]);
|
||||
}
|
||||
|
||||
export function decryptBackup(data: Buffer): string {
|
||||
return Buffer.concat([MAGIC, salt, iv, cipher.getAuthTag(), encrypted]);
|
||||
}
|
||||
|
||||
export function isMdd2Backup(data: Buffer): boolean {
|
||||
return data.length >= MAGIC.length && data.subarray(0, MAGIC.length).equals(MAGIC);
|
||||
}
|
||||
|
||||
export function decryptBackup(data: Buffer, passphrase?: string): string {
|
||||
if (data.length < MAGIC.length) {
|
||||
throw new Error("Backup-Datei zu kurz oder ungültig");
|
||||
}
|
||||
const magic = data.subarray(0, MAGIC.length);
|
||||
if (magic.equals(MAGIC)) {
|
||||
return decryptCurrentBackup(data);
|
||||
const magic = data.subarray(0, MAGIC.length);
|
||||
if (magic.equals(MAGIC)) {
|
||||
return decryptCurrentBackup(data, passphrase);
|
||||
}
|
||||
if (magic.equals(LEGACY_MAGIC)) {
|
||||
return decryptLegacyBackup(data);
|
||||
|
||||
+31
-12
@@ -14,6 +14,7 @@ import { revealHistoryEntry } from "./history-reveal";
|
||||
import { DEV_SERVER_URL } from "./dev-server-url";
|
||||
import { resolveAppIconPath } from "./app-icon";
|
||||
import { configureCredentialProtector } from "./credential-protection";
|
||||
import { isMdd2Backup } from "./backup-crypto";
|
||||
|
||||
function validateString(value: unknown, name: string): string {
|
||||
if (typeof value !== "string") {
|
||||
@@ -73,8 +74,9 @@ let tray: Tray | null = null;
|
||||
let clipboardTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let updateQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastClipboardText = "";
|
||||
let lastClipboardText = "";
|
||||
let controller: AppController;
|
||||
let pendingBackupImport: Buffer | null = null;
|
||||
const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
|
||||
|
||||
function isDevMode(): boolean {
|
||||
@@ -580,7 +582,8 @@ function registerIpcHandlers(): void {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
|
||||
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async (_event: IpcMainInvokeEvent, rawPassphrase: unknown) => {
|
||||
const passphrase = validateString(rawPassphrase, "passphrase");
|
||||
const options = {
|
||||
defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`,
|
||||
filters: [{ name: "MDD Backup", extensions: ["mdd"] }]
|
||||
@@ -589,7 +592,7 @@ function registerIpcHandlers(): void {
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { saved: false };
|
||||
}
|
||||
const encrypted = controller.exportBackup();
|
||||
const encrypted = controller.exportBackup(passphrase);
|
||||
await fs.promises.writeFile(result.filePath, encrypted);
|
||||
return { saved: true };
|
||||
});
|
||||
@@ -766,7 +769,8 @@ function registerIpcHandlers(): void {
|
||||
return controller.checkSingleMegaDebridAccount(String(login || ""), String(password || ""));
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => {
|
||||
ipcMain.handle(IPC_CHANNELS.SELECT_BACKUP_IMPORT, async () => {
|
||||
pendingBackupImport = null;
|
||||
const options = {
|
||||
properties: ["openFile"] as Array<"openFile">,
|
||||
filters: [
|
||||
@@ -775,18 +779,33 @@ function registerIpcHandlers(): void {
|
||||
{ name: "Alle Dateien", extensions: ["*"] }
|
||||
]
|
||||
};
|
||||
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { restored: false, message: "Abgebrochen" };
|
||||
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { selected: false, requiresPassphrase: false, message: "Abgebrochen" };
|
||||
}
|
||||
const filePath = result.filePaths[0];
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
const BACKUP_MAX_BYTES = 50 * 1024 * 1024;
|
||||
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 data = await fs.promises.readFile(filePath);
|
||||
const importResult = controller.importBackup(data);
|
||||
if (stat.size > BACKUP_MAX_BYTES) {
|
||||
return { selected: false, requiresPassphrase: false, message: `Backup-Datei zu groß (max 50 MB, Datei hat ${(stat.size / 1024 / 1024).toFixed(1)} MB)` };
|
||||
}
|
||||
const data = await fs.promises.readFile(filePath);
|
||||
pendingBackupImport = data;
|
||||
return { selected: true, requiresPassphrase: isMdd2Backup(data) };
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.CANCEL_BACKUP_IMPORT, () => {
|
||||
pendingBackupImport = null;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async (_event: IpcMainInvokeEvent, rawPassphrase?: unknown) => {
|
||||
const data = pendingBackupImport;
|
||||
pendingBackupImport = null;
|
||||
if (!data) {
|
||||
return { restored: false, relaunch: false, message: "Keine Backup-Datei ausgewählt" };
|
||||
}
|
||||
const passphrase = typeof rawPassphrase === "string" ? rawPassphrase : undefined;
|
||||
const importResult = controller.importBackup(data, passphrase);
|
||||
// Only a full restore (queue swapped) needs the auto-relaunch. A settings-
|
||||
// only import applied live — relaunching would be pointless and would drop
|
||||
// the running queue.
|
||||
|
||||
Reference in New Issue
Block a user