-
Notifications
You must be signed in to change notification settings - Fork 0
feat(security): encrypt API keys at rest using AES-GCM #264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<LLMConfig>(() => ({ | ||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| setConfig(updated); | ||
| localStorage.setItem(WIZARD_SEEN_KEY, 'true'); | ||
| setShowWizard(false); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| createProvider: vi.fn().mockReturnValue({ | ||
| chatStream: vi.fn().mockImplementation(() => { | ||
| function* gen() { yield { content: '', done: true }; } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,19 +75,23 @@ describe('saveConfig', () => { | |
| localStorage.clear(); | ||
| }); | ||
|
|
||
| it('persists config to localStorage', () => { | ||
| it('persists config to localStorage', async () => { | ||
| const config = { | ||
| activeProvider: 'kilo', | ||
| providers: { | ||
| openrouter: { baseURL: 'https://openrouter.ai/api/v1', apiKey: 'key1', defaultModel: 'm1' }, | ||
| 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 } } }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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'); | ||
| }); | ||
| }); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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,20 +26,54 @@ const DEFAULT_CONFIG: LLMConfig = { | |
| }, | ||
| }; | ||
|
|
||
| export function loadConfig(): LLMConfig { | ||
| export async function loadConfig(): Promise<LLMConfig> { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| try { | ||
| const stored = localStorage.getItem(STORAGE_KEY); | ||
| if (stored) { | ||
| return { ...DEFAULT_CONFIG, ...JSON.parse(stored) as Partial<LLMConfig> }; | ||
| const parsed = JSON.parse(stored) as Partial<LLMConfig>; | ||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| return migrated; | ||
| } | ||
| } catch (e) { | ||
| console.warn('Failed to parse stored LLM config, falling back to defaults', e); | ||
| } | ||
| return { ...DEFAULT_CONFIG }; | ||
| } | ||
|
Comment on lines
+29
to
63
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| export function saveConfig(config: LLMConfig): void { | ||
| localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); | ||
| export async function saveConfig(config: LLMConfig): Promise<void> { | ||
| // 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)); | ||
| } | ||
|
Comment on lines
+65
to
77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| export function createProvider(config: LLMConfig): LLMProvider { | ||
|
|
@@ -69,7 +104,7 @@ export function getProvider(id: string, config?: Partial<LLMProviderConfig>): LL | |
| } | ||
|
|
||
| export function maskApiKey(key: string): string { | ||
| if (!key || key.length < 8) return key ? `...${key.slice(-4)}` : ''; | ||
| if (!key) return ''; | ||
| return `...${key.slice(-4)}`; | ||
| } | ||
|
Comment on lines
106
to
109
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<CryptoKey> { | ||
| 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; | ||
| } | ||
|
Comment on lines
+8
to
+33
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| /** | ||
| * Encrypt a plaintext string using AES-GCM. | ||
| * Returns a prefixed base64 string: `enc:v1:<iv+ciphertext>` | ||
| */ | ||
| export async function encryptApiKey(plaintext: string): Promise<string> { | ||
| 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)); | ||
| } | ||
|
Comment on lines
+39
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| /** | ||
| * 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<string> { | ||
| 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); | ||
| } | ||
|
Comment on lines
+62
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| /** | ||
| * Check if a value is already encrypted. | ||
| */ | ||
| export function isEncrypted(value: string): boolean { | ||
| return value.startsWith(ENCRYPTED_PREFIX); | ||
| } | ||
|
Comment on lines
+87
to
+89
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The void operator takes an operand and returns undefined. It can be used to ignore the value produced by an expression. However, this can lead to code that is difficult to understand and maintain. Historically, the void operator was used to get a "pure" undefined value, as the undefined variable was mutable prior to ES5.