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:
co-authored by
Claude Opus 4.6
parent
621a324859
commit
9e4a2c30e1
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user