feat(app): add encrypted API key vault with IDB storage

Create key-vault.ts with AES-256-GCM encrypted IndexedDB storage for
API keys (Claude, OpenRouter). Add ApiKeyManager.vue settings UI with
masked key display and add/remove functionality. Integrate vault lookups
into useAI.ts streaming functions with graceful fallback when IDB is
unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 20:57:39 +00:00
co-authored by Claude Opus 4.6
parent 621a324859
commit 9e4a2c30e1
3 changed files with 292 additions and 6 deletions
@@ -0,0 +1,155 @@
<template>
<div class="space-y-4">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
API Keys
</h3>
<!-- Configured providers -->
<div class="space-y-2">
<div
v-for="provider in providers"
:key="provider.id"
class="flex items-center gap-3 p-3 rounded-xl"
:class="isDark
? 'bg-white/[0.03] border border-white/5'
: 'bg-black/[0.02] border border-black/5'"
>
<div class="flex-1 min-w-0">
<div
class="text-xs font-medium"
:class="isDark ? 'text-white/80' : 'text-gray-800'"
>
{{ provider.name }}
</div>
<div
class="text-[10px] font-mono mt-0.5"
:class="isDark ? 'text-white/30' : 'text-gray-400'"
>
{{ provider.masked ?? 'Not configured' }}
</div>
</div>
<button
v-if="provider.hasKey"
class="text-[10px] px-2 py-1 rounded-md transition-colors"
:class="isDark
? 'text-red-400/60 hover:text-red-400 hover:bg-red-500/10'
: 'text-red-500/60 hover:text-red-600 hover:bg-red-50'"
@click="removeKey(provider.id)"
>
Remove
</button>
</div>
</div>
<!-- Add new key -->
<div class="space-y-2">
<select
v-model="selectedProvider"
class="w-full px-3 py-2 rounded-lg text-xs outline-none"
:class="isDark
? 'bg-white/5 text-white/80 border border-white/10'
: 'bg-gray-50 text-gray-800 border border-gray-200'"
>
<option value="">Select provider...</option>
<option value="claude">Claude (Anthropic)</option>
<option value="openrouter">OpenRouter</option>
</select>
<div v-if="selectedProvider" class="flex gap-2">
<input
v-model="newKey"
type="password"
placeholder="Paste API key..."
class="flex-1 px-3 py-2 rounded-lg text-xs outline-none"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 border border-white/10'
: 'bg-gray-50 text-gray-800 placeholder:text-gray-400 border border-gray-200'"
style="font-size: 16px"
@keydown.enter="saveKey"
/>
<button
:disabled="!newKey.trim()"
class="px-3 py-2 rounded-lg text-xs font-medium transition-colors"
:class="newKey.trim()
? 'bg-accent text-white hover:bg-accent/90'
: isDark
? 'bg-white/5 text-white/20 cursor-not-allowed'
: 'bg-gray-100 text-gray-300 cursor-not-allowed'"
@click="saveKey"
>
Save
</button>
</div>
<p
v-if="statusMessage"
class="text-[10px]"
:class="statusError ? 'text-red-400' : isDark ? 'text-green-400' : 'text-green-600'"
>
{{ statusMessage }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { storeApiKey, getApiKey, deleteApiKey, listProviders, maskApiKey } from '@/utils/key-vault'
const { isDark } = useTheme()
interface ProviderInfo {
id: string
name: string
hasKey: boolean
masked: string | null
}
const providers = ref<ProviderInfo[]>([])
const selectedProvider = ref('')
const newKey = ref('')
const statusMessage = ref('')
const statusError = ref(false)
const PROVIDER_NAMES: Record<string, string> = {
claude: 'Claude (Anthropic)',
openrouter: 'OpenRouter',
}
async function loadProviders() {
const configured = await listProviders()
const allProviders = ['claude', 'openrouter']
providers.value = await Promise.all(
allProviders.map(async (id) => {
const key = await getApiKey(id)
return {
id,
name: PROVIDER_NAMES[id] ?? id,
hasKey: !!key,
masked: key ? maskApiKey(key) : null,
}
})
)
}
async function saveKey() {
if (!selectedProvider.value || !newKey.value.trim()) return
await storeApiKey(selectedProvider.value, newKey.value.trim())
statusMessage.value = `${PROVIDER_NAMES[selectedProvider.value] ?? selectedProvider.value} key saved`
statusError.value = false
newKey.value = ''
selectedProvider.value = ''
await loadProviders()
}
async function removeKey(provider: string) {
await deleteApiKey(provider)
statusMessage.value = `${PROVIDER_NAMES[provider] ?? provider} key removed`
statusError.value = false
await loadProviders()
}
onMounted(loadProviders)
</script>
+23 -6
View File
@@ -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<void> {
const headers: Record<string, string> = { '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<string, string> = {
'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,
+114
View File
@@ -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<IDBDatabase> {
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<void> {
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<string | null> {
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<void> {
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<string[]> {
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)
}