feat: 对于API key进行本地加密

This commit is contained in:
digua committed 2026-02-04 23:20:25 +08:00
1 parent e687f77c06
commit b1bf4ff8f9
7 files changed
+5279 -6098

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
/**
* API Key 加密工具
* 使用 AES-256-GCM 加密,密钥从机器 ID 派生
*/
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
import { machineIdSync } from 'node-machine-id'
// 加密算法
const ALGORITHM = 'aes-256-gcm'
// 加密前缀,用于标识已加密的数据
const ENCRYPTED_PREFIX = 'enc:'
// 盐值,用于密钥派生(应用级别唯一)
const SALT = 'chatlab-api-key-encryption-v1'
/**
* 从机器 ID 派生加密密钥
* 同一台机器总是生成相同的密钥
*/
function deriveKey(): Buffer {
try {
const machineId = machineIdSync()
return createHash('sha256').update(machineId + SALT).digest()
} catch (error) {
// 如果无法获取机器 ID,使用固定的回退值(安全性降低)
console.warn('无法获取机器 ID,使用回退密钥:', error)
return createHash('sha256').update('chatlab-fallback-key' + SALT).digest()
}
}
// 缓存密钥,避免每次都重新计算
let cachedKey: Buffer | null = null
function getKey(): Buffer {
if (!cachedKey) {
cachedKey = deriveKey()
}
return cachedKey
}
/**
* 加密 API Key
* @param plaintext 明文 API Key
* @returns 加密后的字符串,格式: enc:iv:authTag:ciphertext
*/
export function encryptApiKey(plaintext: string): string {
if (!plaintext) return ''
const key = getKey()
const iv = randomBytes(12) // GCM 推荐 12 字节 IV
const cipher = createCipheriv(ALGORITHM, key, iv)
let encrypted = cipher.update(plaintext, 'utf8', 'base64')
encrypted += cipher.final('base64')
const authTag = cipher.getAuthTag()
// 格式: enc:iv:authTag:ciphertext
return `${ENCRYPTED_PREFIX}${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`
}
/**
* 解密 API Key
* @param encrypted 加密后的字符串
* @returns 解密后的明文,如果解密失败返回空字符串
*/
export function decryptApiKey(encrypted: string): string {
if (!encrypted) return ''
// 如果不是加密格式,直接返回(兼容旧的明文数据)
if (!isEncrypted(encrypted)) {
return encrypted
}
try {
const key = getKey()
// 解析格式: enc:iv:authTag:ciphertext
const parts = encrypted.slice(ENCRYPTED_PREFIX.length).split(':')
if (parts.length !== 3) {
console.warn('加密数据格式错误')
return ''
}
const [ivBase64, authTagBase64, ciphertext] = parts
const iv = Buffer.from(ivBase64, 'base64')
const authTag = Buffer.from(authTagBase64, 'base64')
const decipher = createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(authTag)
let decrypted = decipher.update(ciphertext, 'base64', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
} catch (error) {
console.error('解密 API Key 失败:', error)
return ''
}
}
/**
* 检查字符串是否是加密格式
*/
export function isEncrypted(value: string): boolean {
return value?.startsWith(ENCRYPTED_PREFIX) ?? false
}
+57 -6
View File
@@ -22,6 +22,7 @@ import { MAX_CONFIG_COUNT } from './types'
import { GeminiService, GEMINI_INFO } from './gemini'
import { OpenAICompatibleService, OPENAI_COMPATIBLE_INFO } from './openai-compatible'
import { aiLogger, extractErrorInfo, extractErrorStack } from '../logger'
import { encryptApiKey, decryptApiKey, isEncrypted } from './crypto'
// 导出类型
export * from './types'
@@ -172,7 +173,8 @@ function migrateLegacyConfig(legacy: LegacyStoredConfig): AIConfigStore {
// ==================== 多配置管理 ====================
/**
* 加载配置存储(自动处理迁移)
* 加载配置存储(自动处理迁移和解密)
* 返回的配置中 API Key 已解密
*/
export function loadConfigStore(): AIConfigStore {
const configPath = getConfigPath()
@@ -185,24 +187,73 @@ export function loadConfigStore(): AIConfigStore {
const content = fs.readFileSync(configPath, 'utf-8')
const data = JSON.parse(content)
// 检查是否需要迁移
// 检查是否需要迁移旧格式
if (isLegacyConfig(data)) {
aiLogger.info('LLM', '检测到旧配置格式,执行迁移')
const migrated = migrateLegacyConfig(data)
saveConfigStore(migrated)
return migrated
return loadConfigStore() // 重新加载以触发加密迁移
}
return data as AIConfigStore
} catch {
const store = data as AIConfigStore
// 检查是否需要加密迁移(明文 -> 加密)
let needsEncryptionMigration = false
const decryptedConfigs = store.configs.map((config) => {
if (config.apiKey && !isEncrypted(config.apiKey)) {
// 发现明文 API Key,需要加密迁移
needsEncryptionMigration = true
aiLogger.info('LLM', `配置 "${config.name}" 的 API Key 需要加密迁移`)
}
return {
...config,
apiKey: config.apiKey ? decryptApiKey(config.apiKey) : '',
}
})
// 如果有明文 API Key,执行加密迁移
if (needsEncryptionMigration) {
aiLogger.info('LLM', '执行 API Key 加密迁移')
saveConfigStoreRaw({
...store,
configs: store.configs.map((config) => ({
...config,
apiKey: config.apiKey ? encryptApiKey(decryptApiKey(config.apiKey)) : '',
})),
})
}
return {
...store,
configs: decryptedConfigs,
}
} catch (error) {
aiLogger.error('LLM', '加载配置失败', error)
return { configs: [], activeConfigId: null }
}
}
/**
* 保存配置存储
* 保存配置存储(自动加密 API Key)
* 传入的配置中 API Key 应为明文
*/
export function saveConfigStore(store: AIConfigStore): void {
// 加密所有 API Key 后保存
const encryptedStore: AIConfigStore = {
...store,
configs: store.configs.map((config) => ({
...config,
apiKey: config.apiKey ? encryptApiKey(config.apiKey) : '',
})),
}
saveConfigStoreRaw(encryptedStore)
}
/**
* 保存配置存储(原始写入,不加密)
* 内部使用
*/
function saveConfigStoreRaw(store: AIConfigStore): void {
const configPath = getConfigPath()
const dir = path.dirname(configPath)
+1 -5
View File
@@ -281,10 +281,9 @@ export function registerAIHandlers({ win }: IpcContext): void {
*/
ipcMain.handle('llm:getAllConfigs', async () => {
const configs = llm.getAllConfigs()
// 脱敏 API Key
// 返回 API Key
return configs.map((c) => ({
...c,
apiKey: c.apiKey ? `${c.apiKey.slice(0, 4)}****${c.apiKey.slice(-4)}` : '',
apiKeySet: !!c.apiKey,
}))
})
@@ -320,9 +319,6 @@ export function registerAIHandlers({ win }: IpcContext): void {
success: true,
config: {
...result.config,
apiKey: result.config.apiKey
? `${result.config.apiKey.slice(0, 4)}****${result.config.apiKey.slice(-4)}`
: '',
apiKeySet: !!result.config.apiKey,
},
}
+1
View File
@@ -42,6 +42,7 @@
"echarts-wordcloud": "^2.1.0",
"electron-updater": "^6.6.2",
"markdown-it": "^14.1.0",
"node-machine-id": "^1.1.12",
"stream-json": "^1.9.1",
"vue-echarts": "^8.0.1",
"vue-i18n": "^11.2.8",
+4979 -6011
View File
File diff suppressed because it is too large. Load diff
@@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import AlertTips from './AlertTips.vue'
import ApiKeyInput from './ApiKeyInput.vue'
import Tabs from '@/components/UI/Tabs.vue'
const { t } = useI18n()
@@ -190,7 +191,7 @@ function initFromConfig(config: AIServiceConfig) {
formData.value = {
name: config.name,
provider: config.provider,
apiKey: '',
apiKey: config.apiKey || '', // 编辑时填充已有的 API Key
model: config.model || '',
baseUrl: config.baseUrl || '',
disableThinking: config.disableThinking ?? true, // 默认禁用
@@ -542,37 +543,16 @@ watch(
</div>
<!-- API Key -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">API Key</label>
<div class="flex gap-2">
<UInput
v-model="formData.apiKey"
type="password"
:placeholder="mode === 'edit' ? t('apiKeyPlaceholderEdit') : t('apiKeyPlaceholder')"
class="flex-1"
/>
<UButton :loading="isValidating" :disabled="!formData.apiKey" variant="soft" @click="validateKey">
{{ t('validate') }}
</UButton>
</div>
<!-- 验证结果 -->
<div v-if="validationMessage" class="mt-2">
<div
v-if="validationResult === 'valid'"
class="flex items-center gap-1 text-sm text-green-600 dark:text-green-400"
>
<UIcon name="i-heroicons-check-circle" class="h-4 w-4" />
{{ validationMessage }}
</div>
<div
v-else-if="validationResult === 'invalid'"
class="flex items-center gap-1 text-sm text-amber-600 dark:text-amber-400"
>
<UIcon name="i-heroicons-exclamation-triangle" class="h-4 w-4" />
{{ validationMessage }}
</div>
</div>
</div>
<ApiKeyInput
v-model="formData.apiKey"
:placeholder="t('apiKeyPlaceholder')"
:validate-loading="isValidating"
:validate-disabled="!formData.apiKey"
:validate-text="t('validate')"
:validation-result="validationResult"
:validation-message="validationMessage"
@validate="validateKey"
/>
<!-- 模型选择 -->
<div>
@@ -664,14 +644,12 @@ watch(
</button>
<div v-if="showAdvanced" class="mt-3 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
API Key
<span class="font-normal text-gray-400">{{ t('optional') }}</span>
</label>
<UInput v-model="formData.apiKey" type="password" :placeholder="t('apiKeyPlaceholderLocal')" />
<p class="mt-1 text-xs text-gray-500">{{ t('apiKeyHintLocal') }}</p>
</div>
<ApiKeyInput
v-model="formData.apiKey"
:placeholder="t('apiKeyPlaceholderLocal')"
:optional-text="t('optional')"
:hint="t('apiKeyHintLocal')"
/>
</div>
</div>
</template>
@@ -695,42 +673,16 @@ watch(
</div>
<!-- API Key -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">API Key</label>
<div class="flex gap-2">
<UInput
v-model="formData.apiKey"
type="password"
:placeholder="mode === 'edit' ? t('apiKeyPlaceholderEdit') : t('apiKeyPlaceholder')"
class="flex-1"
/>
<UButton
:loading="isValidating"
:disabled="!formData.apiKey || !formData.baseUrl"
variant="soft"
@click="validateKey"
>
{{ t('validate') }}
</UButton>
</div>
<!-- 验证结果 -->
<div v-if="validationMessage" class="mt-2">
<div
v-if="validationResult === 'valid'"
class="flex items-center gap-1 text-sm text-green-600 dark:text-green-400"
>
<UIcon name="i-heroicons-check-circle" class="h-4 w-4" />
{{ validationMessage }}
</div>
<div
v-else-if="validationResult === 'invalid'"
class="flex items-center gap-1 text-sm text-amber-600 dark:text-amber-400"
>
<UIcon name="i-heroicons-exclamation-triangle" class="h-4 w-4" />
{{ validationMessage }}
</div>
</div>
</div>
<ApiKeyInput
v-model="formData.apiKey"
:placeholder="t('apiKeyPlaceholder')"
:validate-loading="isValidating"
:validate-disabled="!formData.apiKey || !formData.baseUrl"
:validate-text="t('validate')"
:validation-result="validationResult"
:validation-message="validationMessage"
@validate="validateKey"
/>
<!-- 模型名称 -->
<div>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { ref, useAttrs, computed } from 'vue'
const props = withDefaults(
defineProps<{
modelValue: string
placeholder?: string
label?: string
optionalText?: string // 有值则显示 optional 标记
hint?: string
// 验证相关
validateLoading?: boolean
validateDisabled?: boolean
validateText?: string
validationResult?: 'idle' | 'valid' | 'invalid'
validationMessage?: string
}>(),
{
placeholder: '',
label: 'API Key',
optionalText: '',
hint: '',
validateLoading: false,
validateDisabled: false,
validateText: 'Validate',
validationResult: 'idle',
validationMessage: '',
}
)
const emit = defineEmits<{
'update:modelValue': [value: string]
validate: []
}>()
const attrs = useAttrs()
// 通过检测是否监听了 @validate 事件来决定是否显示验证按钮
const showValidateButton = computed(() => 'onValidate' in attrs)
// 控制明文/密文显示
const showPassword = ref(false)
function updateValue(value: string) {
emit('update:modelValue', value)
}
</script>
<template>
<div>
<label v-if="label" class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ label }}
<span v-if="optionalText" class="font-normal text-gray-400">{{ optionalText }}</span>
</label>
<div class="flex gap-2">
<UInput
:model-value="modelValue"
:type="showPassword ? 'text' : 'password'"
:placeholder="placeholder"
:ui="{ trailing: 'pe-1' }"
class="flex-1"
@update:model-value="updateValue"
>
<template v-if="modelValue" #trailing>
<UButton
color="neutral"
variant="link"
size="sm"
:icon="showPassword ? 'i-heroicons-eye-slash' : 'i-heroicons-eye'"
:aria-label="showPassword ? 'Hide' : 'Show'"
:aria-pressed="showPassword"
@click="showPassword = !showPassword"
/>
</template>
</UInput>
<UButton
v-if="showValidateButton"
:loading="validateLoading"
:disabled="validateDisabled"
variant="soft"
@click="emit('validate')"
>
{{ validateText }}
</UButton>
</div>
<!-- 提示文字 -->
<p v-if="hint" class="mt-1 text-xs text-gray-500">{{ hint }}</p>
<!-- 验证结果 -->
<div v-if="validationMessage" class="mt-2">
<div
v-if="validationResult === 'valid'"
class="flex items-center gap-1 text-sm text-green-600 dark:text-green-400"
>
<UIcon name="i-heroicons-check-circle" class="h-4 w-4" />
{{ validationMessage }}
</div>
<div
v-else-if="validationResult === 'invalid'"
class="flex items-center gap-1 text-sm text-amber-600 dark:text-amber-400"
>
<UIcon name="i-heroicons-exclamation-triangle" class="h-4 w-4" />
{{ validationMessage }}
</div>
</div>
</div>
</template>