fix(aiui): Claude API key never persisted in plaintext again (S2)
The key rode the wholesale settings→localStorage save, sitting at rest readable by any same-origin script, while the AES-256-GCM key-vault built for exactly this sat bypassed. Now: the key lives in a memory-only store ref, persists only into the encrypted vault when a passphrase session is active (migrating into the vault on unlock), and a one-time migration lifts any existing plaintext key out of localStorage and re-saves the scrubbed settings object immediately. Settings UI reports honestly how the key is held. Typecheck clean; test suite unchanged (348 pass, 3 pre-existing fails). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
generateSalt,
|
||||
setSessionKey,
|
||||
} from '@/utils/crypto'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
const { currentTheme, initTheme, setTheme } = useTheme()
|
||||
const archy = useArchy()
|
||||
@@ -76,6 +77,9 @@ async function handlePassphraseSubmit(passphrase: string) {
|
||||
|
||||
const key = await deriveKey(passphrase, salt)
|
||||
setSessionKey(key, salt)
|
||||
// Now that a session key exists: pull the Claude key from the encrypted
|
||||
// vault, or migrate a just-scrubbed legacy plaintext key INTO the vault.
|
||||
await useSettingsStore().initClaudeKey()
|
||||
showPassphrase.value = false
|
||||
} catch (err) {
|
||||
console.error('[AIUI] Passphrase error:', err)
|
||||
|
||||
@@ -244,12 +244,12 @@
|
||||
<div class="flex items-center gap-2 p-3 rounded-xl bg-white/[0.03] border border-white/5">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="store.settings.useOwnApiKey && store.settings.claudeApiKey
|
||||
:class="store.settings.useOwnApiKey && store.claudeApiKey
|
||||
? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]'
|
||||
: 'bg-amber-400 shadow-[0_0_6px_rgba(251,191,36,0.4)]'"
|
||||
/>
|
||||
<span class="text-xs text-white/60">
|
||||
{{ store.settings.useOwnApiKey && store.settings.claudeApiKey
|
||||
{{ store.settings.useOwnApiKey && store.claudeApiKey
|
||||
? 'Using your API key'
|
||||
: 'Using server authentication (OAuth)' }}
|
||||
</span>
|
||||
@@ -291,9 +291,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="store.settings.claudeApiKey"
|
||||
v-if="store.claudeApiKey"
|
||||
class="text-xs px-2 py-1 rounded-md text-red-400/50 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
@click="store.settings.claudeApiKey = ''; apiKeyStatus = 'Key removed'"
|
||||
@click="store.setClaudeApiKey(''); apiKeyStatus = 'Key removed'"
|
||||
>
|
||||
Remove key
|
||||
</button>
|
||||
@@ -311,7 +311,7 @@
|
||||
<li>Create a new key (starts with sk-ant-api03-)</li>
|
||||
<li>Paste it above and enable "Use my own API key"</li>
|
||||
</ol>
|
||||
<p class="text-xs text-white/20 mt-2">Your key is stored locally on this device only. Without a key, the server's OAuth authentication is used.</p>
|
||||
<p class="text-xs text-white/20 mt-2">Your key is stored encrypted on this device (AES-256-GCM) once you set an AIUI passphrase; without one it is held in memory for this session only — never written to disk unencrypted. Without a key, the server's OAuth authentication is used.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -357,7 +357,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import type { ContentTab } from '@/composables/contentFiltering'
|
||||
|
||||
@@ -377,6 +377,10 @@ const tabs: { id: Tab; label: string }[] = [
|
||||
const activeTab = ref<Tab>('appearance')
|
||||
const store = useSettingsStore()
|
||||
|
||||
// Populate the key from the encrypted vault when a passphrase session is
|
||||
// already active (standalone). Never touches localStorage (S2).
|
||||
onMounted(() => { store.initClaudeKey() })
|
||||
|
||||
// M15.1 Accent presets
|
||||
const accentPresets = [
|
||||
{ value: '#F7931A', label: 'Bitcoin Orange' },
|
||||
@@ -516,18 +520,20 @@ const apiKeyStatus = ref('')
|
||||
const apiKeyStatusOk = ref(true)
|
||||
|
||||
const apiKeyDisplay = computed(() => {
|
||||
if (apiKeyEditing.value) return store.settings.claudeApiKey
|
||||
if (!store.settings.claudeApiKey) return ''
|
||||
if (showApiKey.value) return store.settings.claudeApiKey
|
||||
const k = store.settings.claudeApiKey
|
||||
if (apiKeyEditing.value) return store.claudeApiKey
|
||||
if (!store.claudeApiKey) return ''
|
||||
if (showApiKey.value) return store.claudeApiKey
|
||||
const k = store.claudeApiKey
|
||||
return k.length > 8 ? k.slice(0, 10) + '...' + k.slice(-4) : '****'
|
||||
})
|
||||
|
||||
function onApiKeyInput(e: Event) {
|
||||
async function onApiKeyInput(e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
store.settings.claudeApiKey = val
|
||||
const held = await store.setClaudeApiKey(val)
|
||||
if (val && val.startsWith('sk-ant-api')) {
|
||||
apiKeyStatus.value = 'Key saved'
|
||||
apiKeyStatus.value = held === 'encrypted'
|
||||
? 'Key saved — stored encrypted'
|
||||
: 'Key saved for this session only — set a passphrase to store it encrypted'
|
||||
apiKeyStatusOk.value = true
|
||||
} else if (val && !val.startsWith('sk-ant-')) {
|
||||
apiKeyStatus.value = 'Key should start with sk-ant-api03-'
|
||||
|
||||
@@ -272,10 +272,14 @@ async function streamClaude(
|
||||
): Promise<void> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
|
||||
// Use user-provided API key from settings if enabled, then vault, then proxy fallback
|
||||
// Use the user's own key when enabled: memory-only store ref first, then
|
||||
// the encrypted vault. The key is never read from localStorage (S2).
|
||||
const settingsStore = useSettingsStore()
|
||||
if (settingsStore.settings.useOwnApiKey && settingsStore.settings.claudeApiKey) {
|
||||
headers['x-api-key'] = settingsStore.settings.claudeApiKey
|
||||
if (settingsStore.settings.useOwnApiKey) {
|
||||
const ownKey = settingsStore.claudeApiKey || await getApiKey('claude')
|
||||
if (ownKey) {
|
||||
headers['x-api-key'] = ownKey
|
||||
}
|
||||
} else {
|
||||
const vaultKey = await getApiKey('claude')
|
||||
if (vaultKey) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { ContentTab } from '@/composables/contentFiltering'
|
||||
import { storeApiKey, getApiKey, deleteApiKey } from '@/utils/key-vault'
|
||||
import { hasSessionKey } from '@/utils/crypto'
|
||||
|
||||
const STORAGE_KEY = 'aiui-settings'
|
||||
|
||||
@@ -24,8 +26,10 @@ export interface AppSettings {
|
||||
defaultPersonaId: string
|
||||
defaultWebSearch: boolean
|
||||
defaultShowTokens: boolean
|
||||
// API key management
|
||||
claudeApiKey: string
|
||||
// API key management — the boolean persists; the KEY NEVER does.
|
||||
// `claudeApiKey` lives only in the memory-only ref exposed by the store
|
||||
// below and, when a passphrase session is active, in the encrypted
|
||||
// key-vault. It is never written to localStorage again (S2).
|
||||
useOwnApiKey: boolean
|
||||
}
|
||||
|
||||
@@ -48,17 +52,71 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
defaultPersonaId: '',
|
||||
defaultWebSearch: false,
|
||||
defaultShowTokens: false,
|
||||
claudeApiKey: '',
|
||||
useOwnApiKey: false,
|
||||
}
|
||||
|
||||
/** Captured by the one-time plaintext migration inside `loadSettings`. */
|
||||
let legacyClaudeKey = ''
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const settings = ref<AppSettings>(loadSettings())
|
||||
|
||||
// The Claude API key — memory-only. Persisted ENCRYPTED in the key-vault
|
||||
// when a passphrase session is active; otherwise it lasts this session
|
||||
// only. Seeded from the one-time plaintext migration above.
|
||||
const claudeApiKey = ref(legacyClaudeKey)
|
||||
|
||||
/** Set/clear the key. Returns how it is held: 'encrypted' (vault) or 'session' (memory-only). */
|
||||
async function setClaudeApiKey(key: string): Promise<'encrypted' | 'session'> {
|
||||
claudeApiKey.value = key
|
||||
if (!key) {
|
||||
await deleteApiKey('claude').catch(() => {})
|
||||
return 'session'
|
||||
}
|
||||
if (hasSessionKey()) {
|
||||
try {
|
||||
await storeApiKey('claude', key)
|
||||
return 'encrypted'
|
||||
} catch { /* fall through to memory-only */ }
|
||||
}
|
||||
return 'session'
|
||||
}
|
||||
|
||||
/** Load the key from the vault (after a passphrase unlock), or migrate a
|
||||
* just-scrubbed legacy key INTO the vault now that a session key exists. */
|
||||
async function initClaudeKey(): Promise<void> {
|
||||
if (claudeApiKey.value) {
|
||||
await persistClaudeKeyToVault()
|
||||
return
|
||||
}
|
||||
const k = await getApiKey('claude').catch(() => null)
|
||||
if (k) claudeApiKey.value = k
|
||||
}
|
||||
|
||||
/** Persist the in-memory key to the vault once a session key exists
|
||||
* (called after a passphrase unlock). No-op without one. */
|
||||
async function persistClaudeKeyToVault(): Promise<void> {
|
||||
if (claudeApiKey.value && hasSessionKey()) {
|
||||
try { await storeApiKey('claude', claudeApiKey.value) } catch { /* stays memory-only */ }
|
||||
}
|
||||
}
|
||||
|
||||
function loadSettings(): AppSettings {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) }
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
// One-time migration (S2): this object used to carry the Claude API
|
||||
// key in plaintext. Lift it into the memory-only path and re-persist
|
||||
// the scrubbed object immediately, so the key never sits at rest in
|
||||
// localStorage again.
|
||||
if (typeof parsed.claudeApiKey === 'string' && parsed.claudeApiKey) {
|
||||
legacyClaudeKey = parsed.claudeApiKey
|
||||
delete parsed.claudeApiKey
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...DEFAULT_SETTINGS, ...parsed }))
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS, ...parsed }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
}
|
||||
@@ -150,6 +208,10 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
|
||||
return {
|
||||
settings,
|
||||
claudeApiKey,
|
||||
setClaudeApiKey,
|
||||
initClaudeKey,
|
||||
persistClaudeKeyToVault,
|
||||
accentColor,
|
||||
glassIntensity,
|
||||
fontSize,
|
||||
|
||||
Reference in New Issue
Block a user