Files
archy/aiui/packages/app/src/utils/key-vault.ts
T

115 lines
3.4 KiB
TypeScript

/**
* Encrypted API key vault using IndexedDB + AES-256-GCM.
* Keys are encrypted with the session key derived from passphrase.
* Refuses to store keys when encryption is unavailable.
*/
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()
if (!isCryptoEnabled() || !sessionKey) {
throw new Error('Encryption required: cannot store API keys without an active session key. Please set a passphrase first.')
}
const value = await encryptToString(key, sessionKey)
const record: VaultRecord = { provider, value, encrypted: true }
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)
}