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:
@@ -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
|
||||
*/
|
||||
|
||||
79
src/main/engine/SecureKeyStore.ts
Normal file
79
src/main/engine/SecureKeyStore.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user