diff --git a/src/main/credential-protection.ts b/src/main/credential-protection.ts new file mode 100644 index 0000000..66673b0 --- /dev/null +++ b/src/main/credential-protection.ts @@ -0,0 +1,121 @@ +import { AppSettings } from "../shared/types"; + +export interface CredentialProtector { + isEncryptionAvailable(): boolean; + encryptString(value: string): Buffer; + decryptString(value: Buffer): string; +} + +const PROTECTED_VALUE_PREFIX = "mdd-safe-storage:v1:"; +const MASKED_CREDENTIAL = "••••••••"; +const CREDENTIAL_KEYS = [ + "token", + "megaLogin", + "megaPassword", + "megaCredentials", + "megaDebridApiCredentials", + "megaDebridWebCredentials", + "bestToken", + "allDebridToken", + "ddownloadLogin", + "ddownloadPassword", + "oneFichierApiKey", + "debridLinkApiKeys", + "linkSnappyLogin", + "linkSnappyPassword" +] as const satisfies readonly (keyof AppSettings)[]; + +let credentialProtector: CredentialProtector = { + isEncryptionAvailable: () => false, + encryptString: () => Buffer.alloc(0), + decryptString: () => "" +}; + +export function configureCredentialProtector(protector: CredentialProtector): void { + credentialProtector = protector; +} + +function isEncryptionAvailable(): boolean { + try { + return credentialProtector.isEncryptionAvailable(); + } catch { + return false; + } +} + +function isProtectedValue(value: string): boolean { + return value.startsWith(PROTECTED_VALUE_PREFIX); +} + +function clearCredentials(settings: AppSettings): AppSettings { + const cleared = { ...settings }; + for (const key of CREDENTIAL_KEYS) { + cleared[key] = ""; + } + return cleared; +} + +export function protectPersistedSettings(settings: AppSettings): AppSettings { + if (settings.rememberToken === false || !isEncryptionAvailable()) { + return clearCredentials(settings); + } + + const protectedSettings = { ...settings }; + for (const key of CREDENTIAL_KEYS) { + const value = typeof settings[key] === "string" ? settings[key] : ""; + if (!value || isProtectedValue(value)) { + protectedSettings[key] = value; + continue; + } + try { + protectedSettings[key] = `${PROTECTED_VALUE_PREFIX}${credentialProtector.encryptString(value).toString("base64")}`; + } catch { + protectedSettings[key] = ""; + } + } + return protectedSettings; +} + +export function restorePersistedSettings(settings: AppSettings): AppSettings { + if (settings.rememberToken === false) { + return clearCredentials(settings); + } + + const restored = { ...settings }; + for (const key of CREDENTIAL_KEYS) { + const value = typeof settings[key] === "string" ? settings[key] : ""; + if (!isProtectedValue(value)) { + restored[key] = value; + continue; + } + if (!isEncryptionAvailable()) { + restored[key] = ""; + continue; + } + try { + restored[key] = credentialProtector.decryptString(Buffer.from(value.slice(PROTECTED_VALUE_PREFIX.length), "base64")); + } catch { + restored[key] = ""; + } + } + return restored; +} + +export function needsPersistedSettingsRewrite(settings: AppSettings): boolean { + const values = CREDENTIAL_KEYS.map((key) => typeof settings[key] === "string" ? settings[key] : "").filter(Boolean); + if (values.length === 0) { + return false; + } + if (settings.rememberToken === false || !isEncryptionAvailable()) { + return true; + } + return values.some((value) => !isProtectedValue(value)); +} + +export function projectSettingsForRenderer(settings: AppSettings): AppSettings { + const projected = { ...settings }; + for (const key of CREDENTIAL_KEYS) { + projected[key] = settings[key] ? MASKED_CREDENTIAL : ""; + } + return projected; +} diff --git a/src/main/main.ts b/src/main/main.ts index dc964ca..bee201e 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron"; +import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, safeStorage, shell, Tray } from "electron"; import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, UpdateInstallProgress } from "../shared/types"; import { AppController } from "./app-controller"; import { IPC_CHANNELS } from "../shared/ipc"; @@ -13,6 +13,7 @@ import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor"; import { revealHistoryEntry } from "./history-reveal"; import { DEV_SERVER_URL } from "./dev-server-url"; import { resolveAppIconPath } from "./app-icon"; +import { configureCredentialProtector } from "./credential-protection"; function validateString(value: unknown, name: string): string { if (typeof value !== "string") { @@ -73,7 +74,7 @@ let clipboardTimer: ReturnType | null = null; let updateQuitTimer: ReturnType | null = null; let scheduledStartTimer: ReturnType | null = null; let lastClipboardText = ""; -const controller = new AppController(); +let controller: AppController; const CLIPBOARD_MAX_TEXT_CHARS = 50_000; function isDevMode(): boolean { @@ -859,8 +860,10 @@ app.on("second-instance", () => { } }); -app.whenReady().then(() => { - cleanupStaleSubstDrives(); +app.whenReady().then(() => { + configureCredentialProtector(safeStorage); + controller = new AppController(); + cleanupStaleSubstDrives(); registerIpcHandlers(); mainWindow = createWindow(); bindMainWindowLifecycle(mainWindow); @@ -892,10 +895,12 @@ app.on("before-quit", () => { if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; } stopClipboardWatcher(); destroyTray(); - shutdownDaemon(); - try { - controller.shutdown(); - } catch (error) { - logger.error(`Fehler beim Shutdown: ${String(error)}`); - } -}); + shutdownDaemon(); + if (controller) { + try { + controller.shutdown(); + } catch (error) { + logger.error(`Fehler beim Shutdown: ${String(error)}`); + } + } +}); diff --git a/src/main/storage.ts b/src/main/storage.ts index cf86313..60eaf2c 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -5,9 +5,10 @@ import path from "node:path"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types"; -import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; -import { defaultSettings } from "./constants"; -import { logger } from "./logger"; +import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; +import { defaultSettings } from "./constants"; +import { needsPersistedSettingsRewrite, protectPersistedSettings, restorePersistedSettings } from "./credential-protection"; +import { logger } from "./logger"; const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]); const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]); @@ -604,32 +605,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { return normalized; } -function sanitizeCredentialPersistence(settings: AppSettings): AppSettings { - if (settings.rememberToken) { - return settings; - } - return { - ...settings, - token: "", - realDebridUseWebLogin: settings.realDebridUseWebLogin, - megaLogin: "", - megaPassword: "", - megaCredentials: "", - megaDebridApiCredentials: "", - megaDebridWebCredentials: "", - bestToken: "", - bestDebridUseWebLogin: settings.bestDebridUseWebLogin, - allDebridToken: "", - ddownloadLogin: "", - ddownloadPassword: "", - oneFichierApiKey: "", - debridLinkApiKeys: "", - linkSnappyLogin: "", - linkSnappyPassword: "" - }; -} - -export interface StoragePaths { +export interface StoragePaths { baseDir: string; configFile: string; sessionFile: string; @@ -717,11 +693,18 @@ function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings { return { ...parsed, megaDebridApiEnabled: preferApi, megaDebridWebEnabled: !preferApi }; } -function readSettingsFile(filePath: string): AppSettings | null { +interface LoadedSettingsFile { + settings: AppSettings; + needsCredentialRewrite: boolean; +} + +function readSettingsFile(filePath: string): LoadedSettingsFile | null { try { const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings; - const migratedLanguage = (parsed as Partial).language === undefined ? "de" : parsed.language; - const migrated = migrateLegacyMegaEnableFlags(parsed); + const needsCredentialRewrite = needsPersistedSettingsRewrite(parsed); + const restored = restorePersistedSettings(parsed); + const migratedLanguage = (restored as Partial).language === undefined ? "de" : restored.language; + const migrated = migrateLegacyMegaEnableFlags(restored); const mergedInput = { ...defaultSettings(), ...migrated, @@ -735,7 +718,7 @@ function readSettingsFile(filePath: string): AppSettings | null { delete (mergedInput as Partial).megaDebridWebDisabledAccountIds; } const merged = normalizeSettings(mergedInput); - return sanitizeCredentialPersistence(merged); + return { settings: merged, needsCredentialRewrite }; } catch (error) { const code = (error as NodeJS.ErrnoException)?.code || ""; if (code === "ENOENT") { @@ -915,35 +898,36 @@ export function normalizeLoadedSession(raw: unknown): SessionState { }; } -export function loadSettings(paths: StoragePaths): AppSettings { +export function loadSettings(paths: StoragePaths): AppSettings { ensureBaseDir(paths.baseDir); if (!fs.existsSync(paths.configFile)) { return defaultSettings(); - } - const loaded = readSettingsFile(paths.configFile); - if (loaded) { - return loaded; - } - - const backupFile = `${paths.configFile}.bak`; - const backupLoaded = fs.existsSync(backupFile) ? readSettingsFile(backupFile) : null; - if (backupLoaded) { - logger.warn("Konfiguration defekt, Backup-Datei wird verwendet"); - try { - const payload = JSON.stringify(backupLoaded, safeJsonReplacer, 2); - const tempPath = `${paths.configFile}.tmp`; - fs.writeFileSync(tempPath, payload, "utf8"); - syncRenameWithExdevFallback(tempPath, paths.configFile); - } catch { - } - return backupLoaded; + } + const loaded = readSettingsFile(paths.configFile); + if (loaded) { + const backupFile = `${paths.configFile}.bak`; + const backupNeedsCredentialRewrite = fs.existsSync(backupFile) + ? needsSettingsFileCredentialRewrite(backupFile) + : false; + if (loaded.needsCredentialRewrite || backupNeedsCredentialRewrite) { + rewriteProtectedSettings(paths, loaded.settings); + } + return loaded.settings; + } + + const backupFile = `${paths.configFile}.bak`; + const backupLoaded = fs.existsSync(backupFile) ? readSettingsFile(backupFile) : null; + if (backupLoaded) { + logger.warn("Konfiguration defekt, Backup-Datei wird verwendet"); + rewriteProtectedSettings(paths, backupLoaded.settings); + return backupLoaded.settings; } logger.error("Konfiguration konnte nicht geladen werden (auch Backup fehlgeschlagen)"); return defaultSettings(); } -function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void { +function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void { try { fs.renameSync(tempPath, targetPath); } catch (renameError: unknown) { @@ -954,7 +938,36 @@ function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void throw renameError; } } -} +} + +function settingsPayload(settings: AppSettings): string { + return JSON.stringify(protectPersistedSettings(normalizeSettings(settings)), safeJsonReplacer, 2); +} + +function writeSettingsFileAtomically(filePath: string, payload: string): void { + const tempPath = `${filePath}.tmp`; + try { + fs.writeFileSync(tempPath, payload, "utf8"); + syncRenameWithExdevFallback(tempPath, filePath); + } catch (error) { + try { fs.rmSync(tempPath, { force: true }); } catch { } + throw error; + } +} + +function rewriteProtectedSettings(paths: StoragePaths, settings: AppSettings): void { + const payload = settingsPayload(settings); + writeSettingsFileAtomically(`${paths.configFile}.bak`, payload); + writeSettingsFileAtomically(paths.configFile, payload); +} + +function needsSettingsFileCredentialRewrite(filePath: string): boolean { + try { + return needsPersistedSettingsRewrite(JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings); + } catch { + return false; + } +} function sessionTempPath(sessionFile: string, kind: "sync" | "async"): string { return `${sessionFile}.${kind}.tmp`; @@ -1036,7 +1049,7 @@ function readSessionFile(filePath: string): SessionState | null { } } -export function saveSettings(paths: StoragePaths, settings: AppSettings): void { +export function saveSettings(paths: StoragePaths, settings: AppSettings): void { syncSettingsSaveGeneration += 1; ensureBaseDir(paths.baseDir); if (fs.existsSync(paths.configFile)) { @@ -1045,8 +1058,7 @@ export function saveSettings(paths: StoragePaths, settings: AppSettings): void { } catch { } } - const persisted = sanitizeCredentialPersistence(normalizeSettings(settings)); - const payload = JSON.stringify(persisted, safeJsonReplacer, 2); + const payload = settingsPayload(settings); const tempPath = `${paths.configFile}.tmp`; try { fs.writeFileSync(tempPath, payload, "utf8"); @@ -1107,12 +1119,11 @@ async function saveSettingsPayloadAsync(paths: StoragePaths, payload: string, ge } } -export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise { - const generation = syncSettingsSaveGeneration; - const persisted = sanitizeCredentialPersistence(normalizeSettings(settings)); - const payload = JSON.stringify(persisted, safeJsonReplacer, 2); - await saveSettingsPayloadAsync(paths, payload, generation); -} +export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise { + const generation = syncSettingsSaveGeneration; + const payload = settingsPayload(settings); + await saveSettingsPayloadAsync(paths, payload, generation); +} export function emptySession(): SessionState { return { diff --git a/tests/credential-protection.test.ts b/tests/credential-protection.test.ts new file mode 100644 index 0000000..076fe09 --- /dev/null +++ b/tests/credential-protection.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { + configureCredentialProtector, + CredentialProtector, + protectPersistedSettings, + projectSettingsForRenderer, + restorePersistedSettings +} from "../src/main/credential-protection"; + +function createProtector(available = true): CredentialProtector { + return { + isEncryptionAvailable: () => available, + encryptString: (value) => Buffer.from(value, "utf8").reverse(), + decryptString: (value) => Buffer.from(value).reverse().toString("utf8") + }; +} + +describe("credential protection", () => { + beforeEach(() => { + configureCredentialProtector(createProtector()); + }); + + it("protects remembered provider values and restores them for the main process", () => { + const input = { + ...defaultSettings(), + rememberToken: true, + token: "value-to-protect", + megaLogin: "account@example.invalid", + megaPassword: "password-value" + }; + + const persisted = protectPersistedSettings(input); + + expect(persisted.token).not.toBe(input.token); + expect(persisted.megaLogin).not.toBe(input.megaLogin); + expect(JSON.stringify(persisted)).not.toContain(input.token); + expect(restorePersistedSettings(persisted)).toMatchObject({ + token: input.token, + megaLogin: input.megaLogin, + megaPassword: input.megaPassword + }); + }); + + it("accepts plaintext values once so existing settings can be migrated", () => { + const input = { + ...defaultSettings(), + rememberToken: true, + token: "legacy-value" + }; + + expect(restorePersistedSettings(input).token).toBe(input.token); + expect(protectPersistedSettings(restorePersistedSettings(input)).token).not.toBe(input.token); + }); + + it("does not persist provider values when encryption is unavailable", () => { + configureCredentialProtector(createProtector(false)); + const persisted = protectPersistedSettings({ + ...defaultSettings(), + rememberToken: true, + token: "ephemeral-value" + }); + + expect(persisted.token).toBe(""); + }); + + it("removes persisted provider values when remembering is disabled", () => { + const persisted = protectPersistedSettings({ + ...defaultSettings(), + rememberToken: false, + token: "ephemeral-value", + megaLogin: "account@example.invalid", + megaPassword: "password-value" + }); + + expect(persisted.token).toBe(""); + expect(persisted.megaLogin).toBe(""); + expect(persisted.megaPassword).toBe(""); + }); + + it("projects only masked presence metadata into renderer settings", () => { + const input = { + ...defaultSettings(), + rememberToken: true, + token: "value-to-protect", + megaDebridApiCredentials: "account@example.invalid:password-value", + debridLinkApiKeys: "key-value" + }; + + const projected = projectSettingsForRenderer(input); + const serialized = JSON.stringify(projected); + + expect(serialized).not.toContain(input.token); + expect(serialized).not.toContain("account@example.invalid"); + expect(serialized).not.toContain("password-value"); + expect(serialized).not.toContain("key-value"); + expect(projected.token).not.toBe(""); + expect(projected.megaDebridApiCredentials).not.toBe(""); + expect(projected.debridLinkApiKeys).not.toBe(""); + }); +}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 9a17085..e13a9c7 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1,15 +1,24 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { AppSettings } from "../src/shared/types"; -import { defaultSettings } from "../src/main/constants"; +import { defaultSettings } from "../src/main/constants"; +import { configureCredentialProtector } from "../src/main/credential-protection"; import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage"; -const tempDirs: string[] = []; +const tempDirs: string[] = []; + +beforeEach(() => { + configureCredentialProtector({ + isEncryptionAvailable: () => true, + encryptString: (value) => Buffer.from(value, "utf8").reverse(), + decryptString: (value) => Buffer.from(value).reverse().toString("utf8") + }); +}); afterEach(() => { for (const dir of tempDirs.splice(0)) { @@ -163,7 +172,7 @@ describe("settings storage", () => { expect(loaded.allDebridToken).toBe(""); }); - it("persists provider credentials when rememberToken is enabled", () => { + it("persists provider credentials when rememberToken is enabled", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir); const paths = createStoragePaths(dir); @@ -183,8 +192,30 @@ describe("settings storage", () => { expect(loaded.megaLogin).toBe("mega-user"); expect(loaded.megaPassword).toBe("mega-pass"); expect(loaded.bestToken).toBe("best-token"); - expect(loaded.allDebridToken).toBe("all-token"); - }); + expect(loaded.allDebridToken).toBe("all-token"); + }); + + it("migrates remembered plaintext provider values without retaining plaintext in config backups", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + const value = "legacy-value-to-migrate"; + fs.writeFileSync(paths.configFile, JSON.stringify({ + ...defaultSettings(), + rememberToken: true, + token: value + }), "utf8"); + + const loaded = loadSettings(paths); + const config = fs.readFileSync(paths.configFile, "utf8"); + const backup = fs.existsSync(`${paths.configFile}.bak`) + ? fs.readFileSync(`${paths.configFile}.bak`, "utf8") + : ""; + + expect(loaded.token).toBe(value); + expect(config).not.toContain(value); + expect(backup).not.toContain(value); + }); it("normalizes invalid enum and numeric values", () => { const normalized = normalizeSettings({