f9e1d2f073
Critical hotfix for the 0.17.0 regression: users upgrading from 0.16.x
were logged out, and their next login wrote a fresh empty secure-store
on top of the original ciphertext — destroying device keys irrecoverably.
Why it happened: loadState used a blanket `catch {}` that conflated
"file doesn't exist (genuine new user)" with "file exists but can't be
decrypted (DPAPI / OSCrypt quirk after the install rename)". Both paths
returned an empty Map; the next scheduledSave then overwrote the
original .bin file with a fresh blob.
Fix:
* Separate ENOENT from decrypt/parse failures. ENOENT → empty Map. Any
other read error → log, empty Map (no quarantine, matches old
behaviour for transient lock issues).
* When decrypt/parse fails the original file is renamed to
<file>.broken-<iso-ts> BEFORE returning empty Map. The next save
writes to a fresh file; the original ciphertext is preserved on disk
so a future build (or manual recovery) can still get at the bytes.
* Loud console.error around the failure so future regressions surface
in main-process logs.
main.ts: move setPath('userData', appData/ChatApp) BEFORE setName so
any productName-derived path caching inside setName can't beat us to
it. Add a startup log of the resolved paths so future debugging has
hard evidence instead of guessing.
Affected users on 0.17.0 should still recover via Settings → Backup
Wiederherstellen (account-level keys are unchanged); this fix prevents
the data destruction for anyone who hasn't upgraded yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
192 lines
6.8 KiB
TypeScript
192 lines
6.8 KiB
TypeScript
// 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: { <key>: <utf8-string-value> }}.
|
|
// 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<string, string>;
|
|
saveTimer: NodeJS.Timeout | null;
|
|
}
|
|
|
|
const handles = new Map<string, HandleState>();
|
|
|
|
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<Map<string, string>> {
|
|
// 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<string, string> };
|
|
return new Map(Object.entries(parsed.entries ?? {}));
|
|
}
|
|
if (rawStr) {
|
|
const parsed = JSON.parse(rawStr) as { version?: number; entries?: Record<string, string> };
|
|
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
|
|
// `<file>.broken-<iso-ts>` 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<void> {
|
|
const obj: Record<string, string> = {};
|
|
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<SecureStoreHandle> => {
|
|
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<string | null> => {
|
|
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<void> => {
|
|
const state = requireState(handle);
|
|
state.entries.set(key, value);
|
|
scheduleSave(state);
|
|
},
|
|
);
|
|
|
|
ipcMain.handle(
|
|
CHANNELS.SECURE_STORE_REMOVE,
|
|
async (_evt, handle: string, key: string): Promise<void> => {
|
|
const state = requireState(handle);
|
|
state.entries.delete(key);
|
|
scheduleSave(state);
|
|
},
|
|
);
|
|
|
|
ipcMain.handle(CHANNELS.SECURE_STORE_CLOSE, async (_evt, handle: string): Promise<void> => {
|
|
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);
|
|
});
|
|
}
|