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:
Sucukdeluxe
2026-08-11 21:38:25 +02:00
parent 2861fd95f7
commit 97ad90ad4f
5 changed files with 349 additions and 80 deletions
+121
View File
@@ -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;
}