feat(security): encrypt persisted provider credentials
Protect every remembered provider credential with Electron safeStorage and restore it only inside the main process. Migrate legacy plaintext settings atomically across the primary config and its backup while keeping credentials memory-only when encryption is unavailable or remembering is disabled. Initialize credential protection after app readiness but before settings are loaded, add masked renderer projection metadata, and cover encryption, migration, unavailable-storage, and persistence behavior with focused tests.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
+7
-2
@@ -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<typeof setInterval> | null = null;
|
||||
let updateQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastClipboardText = "";
|
||||
const controller = new AppController();
|
||||
let controller: AppController;
|
||||
const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
|
||||
|
||||
function isDevMode(): boolean {
|
||||
@@ -860,6 +861,8 @@ app.on("second-instance", () => {
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
configureCredentialProtector(safeStorage);
|
||||
controller = new AppController();
|
||||
cleanupStaleSubstDrives();
|
||||
registerIpcHandlers();
|
||||
mainWindow = createWindow();
|
||||
@@ -893,9 +896,11 @@ app.on("before-quit", () => {
|
||||
stopClipboardWatcher();
|
||||
destroyTray();
|
||||
shutdownDaemon();
|
||||
if (controller) {
|
||||
try {
|
||||
controller.shutdown();
|
||||
} catch (error) {
|
||||
logger.error(`Fehler beim Shutdown: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+53
-42
@@ -7,6 +7,7 @@ import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebri
|
||||
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 { 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"]);
|
||||
@@ -604,31 +605,6 @@ 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 {
|
||||
baseDir: string;
|
||||
configFile: 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<AppSettings>).language === undefined ? "de" : parsed.language;
|
||||
const migrated = migrateLegacyMegaEnableFlags(parsed);
|
||||
const needsCredentialRewrite = needsPersistedSettingsRewrite(parsed);
|
||||
const restored = restorePersistedSettings(parsed);
|
||||
const migratedLanguage = (restored as Partial<AppSettings>).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<AppSettings>).megaDebridWebDisabledAccountIds;
|
||||
}
|
||||
const merged = normalizeSettings(mergedInput);
|
||||
return sanitizeCredentialPersistence(merged);
|
||||
return { settings: merged, needsCredentialRewrite };
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code || "";
|
||||
if (code === "ENOENT") {
|
||||
@@ -922,21 +905,22 @@ export function loadSettings(paths: StoragePaths): AppSettings {
|
||||
}
|
||||
const loaded = readSettingsFile(paths.configFile);
|
||||
if (loaded) {
|
||||
return 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");
|
||||
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;
|
||||
rewriteProtectedSettings(paths, backupLoaded.settings);
|
||||
return backupLoaded.settings;
|
||||
}
|
||||
|
||||
logger.error("Konfiguration konnte nicht geladen werden (auch Backup fehlgeschlagen)");
|
||||
@@ -956,6 +940,35 @@ function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void
|
||||
}
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -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");
|
||||
@@ -1109,8 +1121,7 @@ async function saveSettingsPayloadAsync(paths: StoragePaths, payload: string, ge
|
||||
|
||||
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
|
||||
const generation = syncSettingsSaveGeneration;
|
||||
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
|
||||
const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
|
||||
const payload = settingsPayload(settings);
|
||||
await saveSettingsPayloadAsync(paths, payload, generation);
|
||||
}
|
||||
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
+32
-1
@@ -1,16 +1,25 @@
|
||||
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 { 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[] = [];
|
||||
|
||||
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)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
@@ -186,6 +195,28 @@ describe("settings storage", () => {
|
||||
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({
|
||||
...defaultSettings(),
|
||||
|
||||
Reference in New Issue
Block a user