diff --git a/src/features/ai/AIHarness.tsx b/src/features/ai/AIHarness.tsx index 4d197fa1..7be55d28 100644 --- a/src/features/ai/AIHarness.tsx +++ b/src/features/ai/AIHarness.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { loadConfig, saveConfig, maskApiKey } from '../../lib/llm/config'; +import { loadConfig, saveConfig, maskApiKey, type LLMConfig } from '../../lib/llm/config'; import { saveDbHandles, getDbHandles } from '../../lib/db-persistence'; import { PROVIDER_MODELS } from '../../lib/llm'; import DatabaseSettings from '../../components/DatabaseSettings'; @@ -13,7 +13,18 @@ const WIZARD_SEEN_KEY = 'dks:ai-wizard-seen'; const PROVIDER_MODELS_MAP = new Map(Object.entries(PROVIDER_MODELS)); const AIHarness: React.FC = () => { - const [config, setConfig] = useState(() => loadConfig()); + const [config, setConfig] = useState(() => ({ + activeProvider: 'openrouter', + providers: { + openrouter: { baseURL: 'https://openrouter.ai/api/v1', apiKey: '', defaultModel: 'google/gemini-2.0-flash-lite-preview-02-05:free' }, + kilo: { baseURL: 'https://api.kilo.ai/api/gateway', apiKey: '', defaultModel: 'meta-llama/llama-3.1-8b-instruct' }, + }, + })); + + // Load persisted config (with decrypted keys) on mount + useEffect(() => { + void loadConfig().then(setConfig); + }, []); const [showSettings, setShowSettings] = useState(false); const [editApiKey, setEditApiKey] = useState(''); const [editProvider, setEditProvider] = useState(config.activeProvider); @@ -87,7 +98,7 @@ const AIHarness: React.FC = () => { entries.map(([key, val]) => [key, key === editProvider ? updatedProvider : val]) ), }; - saveConfig(updated); + void saveConfig(updated); setConfig(updated); setShowSettings(false); setEditApiKey(''); @@ -108,7 +119,7 @@ const AIHarness: React.FC = () => { entries.map(([key, val]) => [key, key === provider ? updatedProvider : val]) ), }; - saveConfig(updated); + void saveConfig(updated); setConfig(updated); localStorage.setItem(WIZARD_SEEN_KEY, 'true'); setShowWizard(false); diff --git a/src/features/ai/__tests__/AIHarness.test.tsx b/src/features/ai/__tests__/AIHarness.test.tsx index 065c7a90..08c96240 100644 --- a/src/features/ai/__tests__/AIHarness.test.tsx +++ b/src/features/ai/__tests__/AIHarness.test.tsx @@ -3,14 +3,14 @@ import { screen } from '@testing-library/react'; import { renderWithDb } from '../../../test/test-utils'; vi.mock('../../../lib/llm/config', () => ({ - loadConfig: vi.fn().mockReturnValue({ + loadConfig: vi.fn().mockResolvedValue({ activeProvider: 'openrouter', providers: { openai: { apiKey: '', baseUrl: '', model: 'gpt-4o' }, openrouter: { apiKey: '', baseUrl: '', model: 'google/gemini-2.0-flash-lite-preview-02-05:free' }, }, }), - saveConfig: vi.fn(), + saveConfig: vi.fn().mockResolvedValue(undefined), createProvider: vi.fn().mockReturnValue({ chatStream: vi.fn().mockImplementation(() => { function* gen() { yield { content: '', done: true }; } diff --git a/src/features/ai/useChat.ts b/src/features/ai/useChat.ts index 62da8b2e..20097e30 100644 --- a/src/features/ai/useChat.ts +++ b/src/features/ai/useChat.ts @@ -83,7 +83,7 @@ export function useChat() { } } - const currentConfig = loadConfig(); + const currentConfig = await loadConfig(); const provider = createProvider(currentConfig); const promptMessages = [ diff --git a/src/lib/llm/__tests__/config.test.ts b/src/lib/llm/__tests__/config.test.ts index 125029ce..fba0213a 100644 --- a/src/lib/llm/__tests__/config.test.ts +++ b/src/lib/llm/__tests__/config.test.ts @@ -31,14 +31,14 @@ describe('loadConfig', () => { localStorage.clear(); }); - it('returns default config when localStorage is empty', () => { - const config = loadConfig(); + it('returns default config when localStorage is empty', async () => { + const config = await loadConfig(); expect(config.activeProvider).toBe('openrouter'); expect(config.providers.openrouter.baseURL).toBe('https://openrouter.ai/api/v1'); expect(config.providers.kilo.baseURL).toBe('https://api.kilo.ai/api/gateway'); }); - it('merges saved config with defaults (shallow)', () => { + it('merges saved config with defaults (shallow)', async () => { const saved = { activeProvider: 'kilo', providers: { @@ -56,16 +56,16 @@ describe('loadConfig', () => { }; localStorage.setItem('dks:llm-config', JSON.stringify(saved)); - const config = loadConfig(); + const config = await loadConfig(); expect(config.activeProvider).toBe('kilo'); expect(config.providers.openrouter.apiKey).toBe('test-key'); expect(config.providers.openrouter.baseURL).toBe('https://custom.api/v1'); expect(config.providers.kilo.defaultModel).toBe('custom-kilo'); }); - it('returns defaults on invalid JSON', () => { + it('returns defaults on invalid JSON', async () => { localStorage.setItem('dks:llm-config', 'not-json'); - const config = loadConfig(); + const config = await loadConfig(); expect(config.activeProvider).toBe('openrouter'); }); }); @@ -75,7 +75,7 @@ describe('saveConfig', () => { localStorage.clear(); }); - it('persists config to localStorage', () => { + it('persists config to localStorage', async () => { const config = { activeProvider: 'kilo', providers: { @@ -83,11 +83,15 @@ describe('saveConfig', () => { kilo: { baseURL: 'https://api.kilo.ai/api/gateway', apiKey: 'key2', defaultModel: 'm2' }, }, }; - saveConfig(config); + await saveConfig(config); const stored = JSON.parse(localStorage.getItem('dks:llm-config')!) as { activeProvider: string; providers: { openrouter: { apiKey: string } } }; expect(stored.activeProvider).toBe('kilo'); - expect(stored.providers.openrouter.apiKey).toBe('key1'); + // API key should be encrypted (starts with enc:v1:) + expect(stored.providers.openrouter.apiKey).toMatch(/^enc:v1:/); + // But loading it back should decrypt + const loaded = await loadConfig(); + expect(loaded.providers.openrouter.apiKey).toBe('key1'); }); }); diff --git a/src/lib/llm/config.ts b/src/lib/llm/config.ts index 326ea53c..b7a0c1fe 100644 --- a/src/lib/llm/config.ts +++ b/src/lib/llm/config.ts @@ -1,6 +1,7 @@ import type { LLMProvider, LLMProviderConfig } from './types'; import { OpenRouterProvider } from './openrouter'; import { KiloGatewayProvider } from './kilo'; +import { encryptApiKey, decryptApiKey, isEncrypted } from './encryption'; const STORAGE_KEY = 'dks:llm-config'; @@ -25,11 +26,35 @@ const DEFAULT_CONFIG: LLMConfig = { }, }; -export function loadConfig(): LLMConfig { +export async function loadConfig(): Promise { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { - return { ...DEFAULT_CONFIG, ...JSON.parse(stored) as Partial }; + const parsed = JSON.parse(stored) as Partial; + const config = { ...DEFAULT_CONFIG, ...parsed }; + + // Decrypt provider API keys (migrates plaintext keys on the fly) + const migrated = { ...config, providers: { ...config.providers } }; + let needsSave = false; + for (const [id, providerConfig] of Object.entries(migrated.providers)) { + if (providerConfig.apiKey && providerConfig.apiKey.length > 0) { + migrated.providers[id] = { + ...providerConfig, + apiKey: await decryptApiKey(providerConfig.apiKey), + }; + // Auto-migrate plaintext keys to encrypted + if (!isEncrypted(providerConfig.apiKey)) { + needsSave = true; + } + } + } + + if (needsSave) { + // Re-save with encrypted keys (fire-and-forget) + void saveConfig(migrated); + } + + return migrated; } } catch (e) { console.warn('Failed to parse stored LLM config, falling back to defaults', e); @@ -37,8 +62,18 @@ export function loadConfig(): LLMConfig { return { ...DEFAULT_CONFIG }; } -export function saveConfig(config: LLMConfig): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); +export async function saveConfig(config: LLMConfig): Promise { + // Encrypt all provider API keys before persisting + const encrypted = { ...config, providers: { ...config.providers } }; + for (const [id, providerConfig] of Object.entries(encrypted.providers)) { + if (providerConfig.apiKey && providerConfig.apiKey.length > 0 && !isEncrypted(providerConfig.apiKey)) { + encrypted.providers[id] = { + ...providerConfig, + apiKey: await encryptApiKey(providerConfig.apiKey), + }; + } + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted)); } export function createProvider(config: LLMConfig): LLMProvider { @@ -69,7 +104,7 @@ export function getProvider(id: string, config?: Partial): LL } export function maskApiKey(key: string): string { - if (!key || key.length < 8) return key ? `...${key.slice(-4)}` : ''; + if (!key) return ''; return `...${key.slice(-4)}`; } diff --git a/src/lib/llm/encryption.ts b/src/lib/llm/encryption.ts new file mode 100644 index 00000000..c8f5d974 --- /dev/null +++ b/src/lib/llm/encryption.ts @@ -0,0 +1,89 @@ +const ENCRYPTION_KEY_STORAGE = 'dks:llm-encryption-key'; +const ENCRYPTED_PREFIX = 'enc:v1:'; + +/** + * Get or create the AES-GCM encryption key. + * The key is stored in localStorage as a JWK for persistence across sessions. + */ +async function getKey(): Promise { + const stored = localStorage.getItem(ENCRYPTION_KEY_STORAGE); + if (stored) { + try { + return await crypto.subtle.importKey( + 'jwk', + JSON.parse(stored) as JsonWebKey, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + ); + } catch { + // Key is corrupted, generate a new one + localStorage.removeItem(ENCRYPTION_KEY_STORAGE); + } + } + + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + ); + const exported = await crypto.subtle.exportKey('jwk', key); + localStorage.setItem(ENCRYPTION_KEY_STORAGE, JSON.stringify(exported)); + return key; +} + +/** + * Encrypt a plaintext string using AES-GCM. + * Returns a prefixed base64 string: `enc:v1:` + */ +export async function encryptApiKey(plaintext: string): Promise { + const key = await getKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(plaintext); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encoded, + ); + + // Combine IV + ciphertext and base64 encode + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(encrypted), iv.length); + + return ENCRYPTED_PREFIX + btoa(String.fromCharCode(...combined)); +} + +/** + * Decrypt an encrypted string back to plaintext. + * Supports both encrypted (`enc:v1:...`) and legacy plaintext values. + * If the value is not encrypted, it returns the raw value (migration path). + */ +export async function decryptApiKey(encrypted: string): Promise { + if (!encrypted.startsWith(ENCRYPTED_PREFIX)) { + // Legacy plaintext key — return as-is for migration + return encrypted; + } + + const key = await getKey(); + const base64 = encrypted.slice(ENCRYPTED_PREFIX.length); + const combined = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + + const iv = combined.slice(0, 12); + const ciphertext = combined.slice(12); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ciphertext, + ); + + return new TextDecoder().decode(decrypted); +} + +/** + * Check if a value is already encrypted. + */ +export function isEncrypted(value: string): boolean { + return value.startsWith(ENCRYPTED_PREFIX); +}