fix(security): purge credentials from settings backups
Write the newly protected settings payload to both sync and async config backups so disabling credential persistence or losing encryption availability cannot leave previously stored provider secrets behind. Replace content-based prefix detection with a typed, versioned safeStorage envelope, preserve arbitrary legacy plaintext for migration, and emit field-only diagnostics for encryption failures without exposing credentials or ciphertext. Add regression coverage for both save paths, both persistence-off conditions, and prefix-shaped plaintext values.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { AppSettings } from "../shared/types";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export interface CredentialProtector {
|
||||
isEncryptionAvailable(): boolean;
|
||||
@@ -6,7 +7,6 @@ export interface CredentialProtector {
|
||||
decryptString(value: Buffer): string;
|
||||
}
|
||||
|
||||
const PROTECTED_VALUE_PREFIX = "mdd-safe-storage:v1:";
|
||||
const MASKED_CREDENTIAL = "••••••••";
|
||||
const CREDENTIAL_KEYS = [
|
||||
"token",
|
||||
@@ -24,6 +24,17 @@ const CREDENTIAL_KEYS = [
|
||||
"linkSnappyLogin",
|
||||
"linkSnappyPassword"
|
||||
] as const satisfies readonly (keyof AppSettings)[];
|
||||
type CredentialKey = typeof CREDENTIAL_KEYS[number];
|
||||
|
||||
interface PersistedCredentialEnvelope {
|
||||
type: "safe-storage";
|
||||
version: 1;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export type PersistedAppSettings = Omit<AppSettings, CredentialKey> & {
|
||||
[K in CredentialKey]: string | PersistedCredentialEnvelope;
|
||||
};
|
||||
|
||||
let credentialProtector: CredentialProtector = {
|
||||
isEncryptionAvailable: () => false,
|
||||
@@ -39,12 +50,20 @@ function isEncryptionAvailable(): boolean {
|
||||
try {
|
||||
return credentialProtector.isEncryptionAvailable();
|
||||
} catch {
|
||||
logger.warn("Credential-Verschlüsselungsverfügbarkeit konnte nicht ermittelt werden");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isProtectedValue(value: string): boolean {
|
||||
return value.startsWith(PROTECTED_VALUE_PREFIX);
|
||||
function isPersistedCredentialEnvelope(value: unknown): value is PersistedCredentialEnvelope {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const envelope = value as Partial<PersistedCredentialEnvelope>;
|
||||
return envelope.type === "safe-storage"
|
||||
&& envelope.version === 1
|
||||
&& typeof envelope.payload === "string"
|
||||
&& envelope.payload.length > 0;
|
||||
}
|
||||
|
||||
function clearCredentials(settings: AppSettings): AppSettings {
|
||||
@@ -55,61 +74,73 @@ function clearCredentials(settings: AppSettings): AppSettings {
|
||||
return cleared;
|
||||
}
|
||||
|
||||
export function protectPersistedSettings(settings: AppSettings): AppSettings {
|
||||
export function protectPersistedSettings(settings: AppSettings): PersistedAppSettings {
|
||||
if (settings.rememberToken === false || !isEncryptionAvailable()) {
|
||||
return clearCredentials(settings);
|
||||
}
|
||||
|
||||
const protectedSettings = { ...settings };
|
||||
const protectedSettings = { ...settings } as PersistedAppSettings;
|
||||
for (const key of CREDENTIAL_KEYS) {
|
||||
const value = typeof settings[key] === "string" ? settings[key] : "";
|
||||
if (!value || isProtectedValue(value)) {
|
||||
if (!value) {
|
||||
protectedSettings[key] = value;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
protectedSettings[key] = `${PROTECTED_VALUE_PREFIX}${credentialProtector.encryptString(value).toString("base64")}`;
|
||||
protectedSettings[key] = {
|
||||
type: "safe-storage",
|
||||
version: 1,
|
||||
payload: credentialProtector.encryptString(value).toString("base64")
|
||||
};
|
||||
} catch {
|
||||
protectedSettings[key] = "";
|
||||
logger.warn(`Credential-Verschlüsselung fehlgeschlagen: ${key}`);
|
||||
}
|
||||
}
|
||||
return protectedSettings;
|
||||
}
|
||||
|
||||
export function restorePersistedSettings(settings: AppSettings): AppSettings {
|
||||
export function restorePersistedSettings(settings: AppSettings | PersistedAppSettings): AppSettings {
|
||||
if (settings.rememberToken === false) {
|
||||
return clearCredentials(settings);
|
||||
return clearCredentials(settings as AppSettings);
|
||||
}
|
||||
|
||||
const restored = { ...settings };
|
||||
const restored = { ...settings } as AppSettings;
|
||||
for (const key of CREDENTIAL_KEYS) {
|
||||
const value = typeof settings[key] === "string" ? settings[key] : "";
|
||||
if (!isProtectedValue(value)) {
|
||||
const value = settings[key];
|
||||
if (typeof value === "string") {
|
||||
restored[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (!isPersistedCredentialEnvelope(value)) {
|
||||
restored[key] = "";
|
||||
continue;
|
||||
}
|
||||
if (!isEncryptionAvailable()) {
|
||||
restored[key] = "";
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
restored[key] = credentialProtector.decryptString(Buffer.from(value.slice(PROTECTED_VALUE_PREFIX.length), "base64"));
|
||||
restored[key] = credentialProtector.decryptString(Buffer.from(value.payload, "base64"));
|
||||
} catch {
|
||||
restored[key] = "";
|
||||
logger.warn(`Credential-Entschlüsselung fehlgeschlagen: ${key}`);
|
||||
}
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
export function needsPersistedSettingsRewrite(settings: AppSettings): boolean {
|
||||
const values = CREDENTIAL_KEYS.map((key) => typeof settings[key] === "string" ? settings[key] : "").filter(Boolean);
|
||||
export function needsPersistedSettingsRewrite(settings: AppSettings | PersistedAppSettings): boolean {
|
||||
const values = CREDENTIAL_KEYS.map((key) => settings[key]).filter((value) => {
|
||||
return typeof value === "string" ? value.length > 0 : value !== null && value !== undefined;
|
||||
});
|
||||
if (values.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (settings.rememberToken === false || !isEncryptionAvailable()) {
|
||||
return true;
|
||||
}
|
||||
return values.some((value) => !isProtectedValue(value));
|
||||
return values.some((value) => !isPersistedCredentialEnvelope(value));
|
||||
}
|
||||
|
||||
export function projectSettingsForRenderer(settings: AppSettings): AppSettings {
|
||||
|
||||
+39
-28
@@ -1050,40 +1050,51 @@ function readSessionFile(filePath: string): SessionState | null {
|
||||
}
|
||||
|
||||
export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
||||
syncSettingsSaveGeneration += 1;
|
||||
ensureBaseDir(paths.baseDir);
|
||||
if (fs.existsSync(paths.configFile)) {
|
||||
try {
|
||||
fs.copyFileSync(paths.configFile, `${paths.configFile}.bak`);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
syncSettingsSaveGeneration += 1;
|
||||
ensureBaseDir(paths.baseDir);
|
||||
const payload = settingsPayload(settings);
|
||||
const tempPath = `${paths.configFile}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(tempPath, payload, "utf8");
|
||||
syncRenameWithExdevFallback(tempPath, paths.configFile);
|
||||
} catch (error) {
|
||||
try { fs.rmSync(tempPath, { force: true }); } catch { }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(paths.configFile)) {
|
||||
writeSettingsFileAtomically(`${paths.configFile}.bak`, payload);
|
||||
}
|
||||
writeSettingsFileAtomically(paths.configFile, payload);
|
||||
}
|
||||
|
||||
let asyncSettingsSaveRunning = false;
|
||||
let asyncSettingsSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null;
|
||||
let syncSettingsSaveGeneration = 0;
|
||||
|
||||
async function writeSettingsPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
||||
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
||||
await fsp.copyFile(paths.configFile, `${paths.configFile}.bak`).catch(() => {});
|
||||
const tempPath = `${paths.configFile}.settings.tmp`;
|
||||
await fsp.writeFile(tempPath, payload, "utf8");
|
||||
if (generation < syncSettingsSaveGeneration) {
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fsp.rename(tempPath, paths.configFile);
|
||||
async function writeSettingsPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
||||
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
||||
const tempPath = `${paths.configFile}.settings.tmp`;
|
||||
await fsp.writeFile(tempPath, payload, "utf8");
|
||||
if (generation < syncSettingsSaveGeneration) {
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (fs.existsSync(paths.configFile)) {
|
||||
const backupTempPath = `${paths.configFile}.bak.settings.tmp`;
|
||||
await fsp.writeFile(backupTempPath, payload, "utf8");
|
||||
if (generation < syncSettingsSaveGeneration) {
|
||||
await Promise.all([
|
||||
fsp.rm(tempPath, { force: true }).catch(() => {}),
|
||||
fsp.rm(backupTempPath, { force: true }).catch(() => {})
|
||||
]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fsp.rename(backupTempPath, `${paths.configFile}.bak`);
|
||||
} catch (renameError: unknown) {
|
||||
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
await fsp.copyFile(backupTempPath, `${paths.configFile}.bak`);
|
||||
await fsp.rm(backupTempPath, { force: true }).catch(() => {});
|
||||
} else {
|
||||
await fsp.rm(backupTempPath, { force: true }).catch(() => {});
|
||||
throw renameError;
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fsp.rename(tempPath, paths.configFile);
|
||||
} catch (renameError: unknown) {
|
||||
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
if (generation < syncSettingsSaveGeneration) {
|
||||
|
||||
Reference in New Issue
Block a user