diff --git a/packages/app/src/components/settings/ApiKeyManager.vue b/packages/app/src/components/settings/ApiKeyManager.vue new file mode 100644 index 00000000..fc5e050b --- /dev/null +++ b/packages/app/src/components/settings/ApiKeyManager.vue @@ -0,0 +1,155 @@ + + + + API Keys + + + + + + + + {{ provider.name }} + + + {{ provider.masked ?? 'Not configured' }} + + + + Remove + + + + + + + + Select provider... + Claude (Anthropic) + OpenRouter + + + + + + Save + + + + + {{ statusMessage }} + + + + + + diff --git a/packages/app/src/composables/useAI.ts b/packages/app/src/composables/useAI.ts index 1a28c457..9073f60a 100644 --- a/packages/app/src/composables/useAI.ts +++ b/packages/app/src/composables/useAI.ts @@ -1,6 +1,7 @@ import { ref, computed } from 'vue' import { useChatStore } from '@/stores/chat' import { searchWeb } from '@/composables/useWebSearch' +import { getApiKey } from '@/utils/key-vault' type Provider = 'claude' | 'openrouter' | 'mock' @@ -136,9 +137,17 @@ async function streamClaude( webSearch: boolean, signal?: AbortSignal, ): Promise { + const headers: Record = { 'Content-Type': 'application/json' } + + // Use vault key if available, proxy uses its own key as fallback + const vaultKey = await getApiKey('claude') + if (vaultKey) { + headers['x-api-key'] = vaultKey + } + const res = await fetch(CLAUDE_PATH, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, body: JSON.stringify({ model: activeModel.value, system: systemPrompt, @@ -177,13 +186,21 @@ async function streamOpenRouter( ...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })), ] + const headers: Record = { + 'Content-Type': 'application/json', + 'HTTP-Referer': window.location.origin, + 'X-Title': 'AIUI', + } + + // Use vault key if available, otherwise proxy handles auth + const vaultKey = await getApiKey('openrouter') + if (vaultKey) { + headers['Authorization'] = `Bearer ${vaultKey}` + } + const res = await fetch(OPENROUTER_PATH, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'HTTP-Referer': window.location.origin, - 'X-Title': 'AIUI', - }, + headers, body: JSON.stringify({ model: activeModel.value, messages: orMessages, diff --git a/packages/app/src/utils/key-vault.ts b/packages/app/src/utils/key-vault.ts new file mode 100644 index 00000000..a54f70ff --- /dev/null +++ b/packages/app/src/utils/key-vault.ts @@ -0,0 +1,114 @@ +/** + * Encrypted API key vault using IndexedDB + AES-256-GCM. + * Keys are encrypted with the session key derived from passphrase. + * Falls back to plaintext storage when crypto is disabled (dev mode). + */ +import { + isCryptoEnabled, + getSessionKey, + encryptToString, + decryptFromString, +} from './crypto' + +const DB_NAME = 'aiui-vault' +const DB_VERSION = 1 +const STORE_NAME = 'api-keys' + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION) + req.onupgradeneeded = () => { + const db = req.result + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'provider' }) + } + } + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) +} + +interface VaultRecord { + provider: string + value: string // encrypted or plaintext API key + encrypted: boolean +} + +export async function storeApiKey(provider: string, key: string): Promise { + if (typeof indexedDB === 'undefined') return + const db = await openDB() + const sessionKey = getSessionKey() + const useEncryption = isCryptoEnabled() && !!sessionKey + + const value = useEncryption + ? await encryptToString(key, sessionKey!) + : key + + const record: VaultRecord = { provider, value, encrypted: useEncryption } + + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + tx.objectStore(STORE_NAME).put(record) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) +} + +export async function getApiKey(provider: string): Promise { + if (typeof indexedDB === 'undefined') return null + const db = await openDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly') + const req = tx.objectStore(STORE_NAME).get(provider) + req.onsuccess = async () => { + const record = req.result as VaultRecord | undefined + if (!record) { + resolve(null) + return + } + if (record.encrypted) { + const sessionKey = getSessionKey() + if (!sessionKey) { + resolve(null) + return + } + try { + const decrypted = await decryptFromString(record.value, sessionKey) + resolve(decrypted) + } catch { + resolve(null) + } + } else { + resolve(record.value) + } + } + req.onerror = () => reject(req.error) + }) +} + +export async function deleteApiKey(provider: string): Promise { + if (typeof indexedDB === 'undefined') return + const db = await openDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite') + tx.objectStore(STORE_NAME).delete(provider) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) +} + +export async function listProviders(): Promise { + if (typeof indexedDB === 'undefined') return [] + const db = await openDB() + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly') + const req = tx.objectStore(STORE_NAME).getAllKeys() + req.onsuccess = () => resolve(req.result as string[]) + req.onerror = () => reject(req.error) + }) +} + +export function maskApiKey(key: string): string { + if (key.length <= 4) return '****' + return '****' + key.slice(-4) +}
+ {{ statusMessage }} +