// 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> { try { if (encrypted) { const buf = await fs.readFile(filePath); const json = safeStorage.decryptString(buf); const parsed = JSON.parse(json) as { version?: number; entries?: Record }; return new Map(Object.entries(parsed.entries ?? {})); } else { const raw = await fs.readFile(filePath, 'utf8'); const parsed = JSON.parse(raw) as { version?: number; entries?: Record }; return new Map(Object.entries(parsed.entries ?? {})); } } catch { // Missing file or malformed contents — start fresh. The next write // will overwrite with a fresh blob. 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); }); }