feat: migrate API key storage to Electron safeStorage (OS keychain)

- Add SecureKeyStore class using safeStorage encrypt/decrypt with base64 in SQLite
- Update chatHandlers to store/retrieve API keys via SecureKeyStore
- Delete old plain-text opencode_api_key on startup (no migration, re-enter key)
- Add deleteSetting() to ChatEngine
- Add 14 SecureKeyStore unit tests and 6 chatHandlers keychain integration tests
- Update existing chatHandlers test mocks for SecureKeyStore
- Update MISTRAL_PLAN.md: mark PR 1 done, remove legacy fallback from PR 2 scope
This commit is contained in:
2026-03-01 12:36:35 +01:00
parent 2a58699398
commit 0618c7c532
7 changed files with 567 additions and 27 deletions

View File

@@ -394,6 +394,17 @@ CRITICAL - Heatmap and complex visualizations:
});
}
/**
* Delete a setting by key
*/
async deleteSetting(key: string): Promise<void> {
const drizzle = this.db.getLocal();
await drizzle
.delete(settings)
.where(eq(settings.key, key));
}
/**
* Get selected model for new conversations
*/

View File

@@ -0,0 +1,79 @@
/**
* SecureKeyStore - Encrypts API keys using Electron's safeStorage (OS keychain)
*
* Uses safeStorage to encrypt strings via the OS-native credential store:
* - macOS: Keychain
* - Windows: DPAPI
* - Linux: libsecret
*
* Encrypted values are stored as base64 strings in the SQLite settings table
* under a `__encrypted_` prefixed key. No plain-text fallback.
*/
import { safeStorage } from 'electron';
import type { ChatEngine } from './ChatEngine';
const ENCRYPTED_PREFIX = '__encrypted_';
export class SecureKeyStore {
private engine: ChatEngine;
constructor(engine: ChatEngine) {
this.engine = engine;
}
/**
* Check if safeStorage encryption is available on this platform.
*/
isAvailable(): boolean {
return safeStorage.isEncryptionAvailable();
}
/**
* Encrypt and store a value in the settings table.
* @throws if safeStorage is not available
*/
async store(key: string, value: string): Promise<void> {
if (!this.isAvailable()) {
throw new Error('Secure storage is not available on this platform');
}
const encrypted = safeStorage.encryptString(value);
const base64 = encrypted.toString('base64');
await this.engine.setSetting(`${ENCRYPTED_PREFIX}${key}`, base64);
}
/**
* Retrieve and decrypt a value from the settings table.
* @returns the decrypted value, or null if the key does not exist
* @throws if safeStorage is not available
*/
async retrieve(key: string): Promise<string | null> {
if (!this.isAvailable()) {
throw new Error('Secure storage is not available on this platform');
}
const base64 = await this.engine.getSetting(`${ENCRYPTED_PREFIX}${key}`);
if (base64 === null) {
return null;
}
const encrypted = Buffer.from(base64, 'base64');
return safeStorage.decryptString(encrypted);
}
/**
* Remove an encrypted key from the settings table.
*/
async remove(key: string): Promise<void> {
await this.engine.deleteSetting(`${ENCRYPTED_PREFIX}${key}`);
}
/**
* Delete a plain-text key from the settings table.
* Used during upgrade to clean up old unencrypted API keys.
*/
async cleanupPlainTextKey(key: string): Promise<void> {
await this.engine.deleteSetting(key);
}
}

View File

@@ -5,11 +5,13 @@
import { ipcMain, BrowserWindow } from 'electron';
import { ChatEngine } from '../engine/ChatEngine';
import { OpenCodeManager } from '../engine/OpenCodeManager';
import { SecureKeyStore } from '../engine/SecureKeyStore';
import { getDatabase } from '../database';
import type { EngineBundle } from '../engine/EngineBundle';
let chatEngine: ChatEngine | null = null;
let openCodeManager: OpenCodeManager | null = null;
let secureKeyStore: SecureKeyStore | null = null;
let openCodeManagerInitPromise: Promise<void> | null = null;
let mainWindowGetter: (() => BrowserWindow | null) | null = null;
let engineBundle: EngineBundle | null = null;
@@ -47,11 +49,16 @@ async function getOpenCodeManager(): Promise<OpenCodeManager> {
() => mainWindowGetter?.() || null
);
// Load API key from settings and await it
// Initialize secure key store and load API key
const engine = getChatEngine();
secureKeyStore = new SecureKeyStore(engine);
openCodeManagerInitPromise = (async () => {
try {
const key = await engine.getSetting('opencode_api_key');
// Clean up old plain-text key from settings (pre-keychain storage)
await secureKeyStore!.cleanupPlainTextKey('opencode_api_key');
// Load API key from encrypted storage
const key = await secureKeyStore!.retrieve('opencode_api_key');
if (key) {
openCodeManager!.setApiKey(key);
}
@@ -109,9 +116,8 @@ export function registerChatHandlers(): void {
const manager = await getOpenCodeManager();
manager.setApiKey(apiKey);
// Persist to settings
const engine = getChatEngine();
await engine.setSetting('opencode_api_key', apiKey);
// Persist to encrypted storage
await secureKeyStore!.store('opencode_api_key', apiKey);
return { success: true };
} catch (error) {
@@ -411,5 +417,6 @@ export async function cleanupChatHandlers(): Promise<void> {
openCodeManager = null;
}
openCodeManagerInitPromise = null;
secureKeyStore = null;
chatEngine = null;
}