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:
Sucukdeluxe
2026-08-11 21:50:02 +02:00
parent 97ad90ad4f
commit 5af7b5c32f
4 changed files with 192 additions and 46 deletions
+39 -28
View File
@@ -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) {