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:
archipelago
2026-08-07 06:28:51 -04:00
co-authored by Claude
parent a3edb848e1
commit 4e6df488fc
4 changed files with 96 additions and 20 deletions
+4
View File
@@ -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-'
+7 -3
View File
@@ -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) {
+66 -4
View File
@@ -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,