// Per-user secure key/value store. Replaces Tauri's plugin-stronghold + // custom file vault with Electron's safeStorage (DPAPI on Windows, // Keychain on macOS, libsecret on Linux). Encryption is at the file // level — the whole entries map is a single encrypted blob — so there's // no per-set ciphertext rotation to track. // // Pre-encrypt JSON: {version:1, entries: { : }}. // When safeStorage is unavailable we degrade to a `.plaintext` JSON file // and flag `encrypted: false` back to the renderer so the renderer can // warn the user and avoid long-lived secrets. import { app, ipcMain, safeStorage } from 'electron'; import { createHash } from 'node:crypto'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { CHANNELS, type SecureStoreHandle, type SecureStoreOpenArgs, } from '../ipc-types'; interface HandleState { filePath: string; encrypted: boolean; entries: Map; saveTimer: NodeJS.Timeout | null; } const handles = new Map(); function hashUserId(userId: string): string { return createHash('sha256').update(userId).digest('hex').slice(0, 16); } function filePathFor(userId: string, encrypted: boolean): string { const suffix = hashUserId(userId); const ext = encrypted ? 'bin' : 'plaintext'; return path.join(app.getPath('userData'), `chatapp-secure-${suffix}.${ext}`); } async function loadState(filePath: string, encrypted: boolean): Promise> { // Read step. Distinguish "no file yet" (genuinely new user — empty Map // is correct) from "file exists but unreadable" (corruption / DPAPI // breakage — we MUST NOT let the next write overwrite those bytes, // because the original ciphertext is the only path back to the user's // device keys if a future build can fix the read path). let rawBuf: Buffer | null = null; let rawStr: string | null = null; try { if (encrypted) { rawBuf = await fs.readFile(filePath); } else { rawStr = await fs.readFile(filePath, 'utf8'); } } catch (err: unknown) { const code = (err as NodeJS.ErrnoException | null)?.code; if (code === 'ENOENT') return new Map(); console.warn('[secure-store] read failed (non-ENOENT)', filePath, err); // For non-ENOENT read failures (EACCES, EBUSY, …) don't quarantine — // the file might be transiently locked. Empty map + future writes // will attempt to overwrite, matching the pre-0.17.1 behaviour for // these rarer cases. return new Map(); } // Parse / decrypt step. try { if (encrypted && rawBuf) { const json = safeStorage.decryptString(rawBuf); const parsed = JSON.parse(json) as { version?: number; entries?: Record }; return new Map(Object.entries(parsed.entries ?? {})); } if (rawStr) { const parsed = JSON.parse(rawStr) as { version?: number; entries?: Record }; return new Map(Object.entries(parsed.entries ?? {})); } return new Map(); } catch (err: unknown) { // CRITICAL: file existed but we couldn't decrypt or parse it. In the // pre-0.17.1 build we silently started fresh — the next set() then // scheduledSave() over-wrote the original ciphertext, destroying the // user's device keys forever. Now we rename the original to // `.broken-` BEFORE returning the empty map so the next // write goes to a new file and the original bytes survive for // forensics or a future decrypt-recovery path. const ts = new Date().toISOString().replace(/[:.]/g, '-'); const brokenPath = `${filePath}.broken-${ts}`; try { await fs.rename(filePath, brokenPath); console.error( `[secure-store] DECRYPT/PARSE FAILED for ${filePath} — preserved original at ${brokenPath}. Original error:`, err, ); } catch (renameErr: unknown) { // Even rename failed — fall back to the old behaviour (silent empty // map) but log loudly so it's visible in the main-process output. console.error( '[secure-store] rename of broken file failed; original may be overwritten on next save', renameErr, 'original decrypt error:', err, ); } return new Map(); } } async function writeStateNow(state: HandleState): Promise { const obj: Record = {}; for (const [k, v] of state.entries) obj[k] = v; const serialised = JSON.stringify({ version: 1, entries: obj }); await fs.mkdir(path.dirname(state.filePath), { recursive: true }); if (state.encrypted) { const buf = safeStorage.encryptString(serialised); await fs.writeFile(state.filePath, buf); } else { await fs.writeFile(state.filePath, serialised, 'utf8'); } } function scheduleSave(state: HandleState): void { if (state.saveTimer) clearTimeout(state.saveTimer); state.saveTimer = setTimeout(() => { state.saveTimer = null; void writeStateNow(state).catch((err: unknown) => { console.warn('[secure-store] persist failed', err); }); }, 100); } function requireState(handle: string): HandleState { const s = handles.get(handle); if (!s) throw new Error('secure-store: unknown handle'); return s; } export function register(): void { ipcMain.handle( CHANNELS.SECURE_STORE_OPEN, async (_evt, args: SecureStoreOpenArgs): Promise => { const encrypted = safeStorage.isEncryptionAvailable(); const filePath = filePathFor(args.userId, encrypted); const entries = await loadState(filePath, encrypted); const handle = hashUserId(args.userId); handles.set(handle, { filePath, encrypted, entries, saveTimer: null }); return { handle, encrypted }; }, ); ipcMain.handle( CHANNELS.SECURE_STORE_GET, async (_evt, handle: string, key: string): Promise => { const state = requireState(handle); return state.entries.get(key) ?? null; }, ); ipcMain.handle( CHANNELS.SECURE_STORE_SET, async (_evt, handle: string, key: string, value: string): Promise => { const state = requireState(handle); state.entries.set(key, value); scheduleSave(state); }, ); ipcMain.handle( CHANNELS.SECURE_STORE_REMOVE, async (_evt, handle: string, key: string): Promise => { const state = requireState(handle); state.entries.delete(key); scheduleSave(state); }, ); ipcMain.handle(CHANNELS.SECURE_STORE_CLOSE, async (_evt, handle: string): Promise => { const state = handles.get(handle); if (!state) return; if (state.saveTimer) { clearTimeout(state.saveTimer); state.saveTimer = null; try { await writeStateNow(state); } catch (err: unknown) { console.warn('[secure-store] close-flush failed', err); } } handles.delete(handle); }); }