mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat: 对于API key进行本地加密
This commit is contained in:
1 parent
e687f77c06
commit
b1bf4ff8f9
7 files changed
+5279
-6098
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+4979
-6011
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>
|
||||
Reference in new issue
Block a user