mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-23 06:51:10 +08:00
feat: 使用原生逻辑替换node-machine-id依赖以解决部分Linux无法修改API密钥的问题(resolve #145)
This commit is contained in:
+107
-38
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* API Key 加密工具
|
||||
* 使用 AES-256-GCM 加密,密钥从机器 ID 派生
|
||||
* 使用 AES-256-GCM 加密,密钥从持久化的设备密钥派生
|
||||
*/
|
||||
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
|
||||
import { machineIdSync } from 'node-machine-id'
|
||||
import { execSync } from 'child_process'
|
||||
import { getDeviceKey } from './device-key'
|
||||
|
||||
// 加密算法
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
@@ -14,25 +15,76 @@ 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('Failed to get machine ID, using fallback key:', error)
|
||||
return createHash('sha256')
|
||||
.update('chatlab-fallback-key' + SALT)
|
||||
.digest()
|
||||
}
|
||||
const deviceKey = getDeviceKey()
|
||||
return createHash('sha256')
|
||||
.update(deviceKey + SALT)
|
||||
.digest()
|
||||
}
|
||||
|
||||
// 缓存密钥,避免每次都重新计算
|
||||
/**
|
||||
* 从旧的 machine-id 方案派生密钥(用于迁移)
|
||||
* 尝试读取系统 machine-id,如果失败则尝试 fallback key
|
||||
*/
|
||||
function deriveLegacyKeys(): Buffer[] {
|
||||
const keys: Buffer[] = []
|
||||
try {
|
||||
const platform = process.platform
|
||||
let cmd: string | null = null
|
||||
if (platform === 'linux') {
|
||||
cmd = '( cat /var/lib/dbus/machine-id /etc/machine-id 2> /dev/null || hostname ) | head -n 1 || :'
|
||||
} else if (platform === 'darwin') {
|
||||
cmd = 'ioreg -rd1 -c IOPlatformExpertDevice'
|
||||
} else if (platform === 'win32') {
|
||||
cmd = 'REG.exe QUERY HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid'
|
||||
}
|
||||
|
||||
if (cmd) {
|
||||
const raw = execSync(cmd).toString()
|
||||
let machineId: string
|
||||
if (platform === 'darwin') {
|
||||
machineId =
|
||||
raw
|
||||
.split('IOPlatformUUID')[1]
|
||||
?.split('\n')[0]
|
||||
?.replace(/[=\s"]/g, '')
|
||||
?.toLowerCase() || ''
|
||||
} else if (platform === 'win32') {
|
||||
machineId =
|
||||
raw
|
||||
.split('REG_SZ')[1]
|
||||
?.replace(/[\r\n\s]/g, '')
|
||||
?.toLowerCase() || ''
|
||||
} else {
|
||||
machineId = raw.replace(/[\r\n\s]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
if (machineId) {
|
||||
// node-machine-id 默认会对 machine-id 做一次 sha256 hash
|
||||
const hashed = createHash('sha256').update(machineId).digest('hex')
|
||||
keys.push(
|
||||
createHash('sha256')
|
||||
.update(hashed + SALT)
|
||||
.digest()
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 系统命令失败,忽略
|
||||
}
|
||||
|
||||
// 旧版 fallback key
|
||||
keys.push(
|
||||
createHash('sha256')
|
||||
.update('chatlab-fallback-key' + SALT)
|
||||
.digest()
|
||||
)
|
||||
return keys
|
||||
}
|
||||
|
||||
// 缓存密钥
|
||||
let cachedKey: Buffer | null = null
|
||||
|
||||
function getKey(): Buffer {
|
||||
@@ -64,27 +116,12 @@ export function encryptApiKey(plaintext: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密 API Key
|
||||
* @param encrypted 加密后的字符串
|
||||
* @returns 解密后的明文,如果解密失败返回空字符串
|
||||
* 用指定密钥尝试解密
|
||||
*/
|
||||
export function decryptApiKey(encrypted: string): string {
|
||||
if (!encrypted) return ''
|
||||
|
||||
// 如果不是加密格式,直接返回(兼容旧的明文数据)
|
||||
if (!isEncrypted(encrypted)) {
|
||||
return encrypted
|
||||
}
|
||||
|
||||
function tryDecryptWithKey(encrypted: string, key: Buffer): string | null {
|
||||
try {
|
||||
const key = getKey()
|
||||
|
||||
// 解析格式: enc:iv:authTag:ciphertext
|
||||
const parts = encrypted.slice(ENCRYPTED_PREFIX.length).split(':')
|
||||
if (parts.length !== 3) {
|
||||
console.warn('Encrypted data format error')
|
||||
return ''
|
||||
}
|
||||
if (parts.length !== 3) return null
|
||||
|
||||
const [ivBase64, authTagBase64, ciphertext] = parts
|
||||
const iv = Buffer.from(ivBase64, 'base64')
|
||||
@@ -97,12 +134,44 @@ export function decryptApiKey(encrypted: string): string {
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt API Key:', error)
|
||||
return ''
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密 API Key
|
||||
* 优先使用当前密钥,失败时尝试旧版 machine-id 密钥(自动迁移)
|
||||
* @param encrypted 加密后的字符串
|
||||
* @returns 解密后的明文,如果解密失败返回空字符串
|
||||
*/
|
||||
export function decryptApiKey(encrypted: string): string {
|
||||
if (!encrypted) return ''
|
||||
|
||||
// 如果不是加密格式,直接返回(兼容旧的明文数据)
|
||||
if (!isEncrypted(encrypted)) {
|
||||
return encrypted
|
||||
}
|
||||
|
||||
// 尝试当前密钥
|
||||
const currentKey = getKey()
|
||||
const result = tryDecryptWithKey(encrypted, currentKey)
|
||||
if (result !== null) return result
|
||||
|
||||
// 当前密钥失败,尝试旧版 machine-id 密钥(迁移场景)
|
||||
const legacyKeys = deriveLegacyKeys()
|
||||
for (const legacyKey of legacyKeys) {
|
||||
const legacyResult = tryDecryptWithKey(encrypted, legacyKey)
|
||||
if (legacyResult !== null) {
|
||||
console.log('[Crypto] Decrypted with legacy key, migration needed')
|
||||
return legacyResult
|
||||
}
|
||||
}
|
||||
|
||||
console.error('[Crypto] Failed to decrypt API Key with all available keys')
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查字符串是否是加密格式
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 设备密钥管理
|
||||
* 在应用数据目录下持久化一个随机生成的设备密钥,用于 API Key 加密。
|
||||
* 替代 node-machine-id,解决 Linux ARM64 等环境下 machine-id 不可用或不稳定的问题。
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { getAppDataDir, ensureDir } from '../../paths'
|
||||
|
||||
const DEVICE_KEY_FILE = '.device-key'
|
||||
|
||||
let cachedDeviceKey: string | null = null
|
||||
|
||||
/**
|
||||
* 获取设备密钥(32 字节随机值的 hex 字符串)
|
||||
* 首次调用时从文件读取,文件不存在则生成并写入。
|
||||
*/
|
||||
export function getDeviceKey(): string {
|
||||
if (cachedDeviceKey) return cachedDeviceKey
|
||||
|
||||
const dataDir = getAppDataDir()
|
||||
ensureDir(dataDir)
|
||||
const keyPath = path.join(dataDir, DEVICE_KEY_FILE)
|
||||
|
||||
try {
|
||||
if (fs.existsSync(keyPath)) {
|
||||
const key = fs.readFileSync(keyPath, 'utf-8').trim()
|
||||
if (key.length >= 32) {
|
||||
cachedDeviceKey = key
|
||||
return cachedDeviceKey
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[DeviceKey] Failed to read device key file:', error)
|
||||
}
|
||||
|
||||
// 生成新密钥
|
||||
const newKey = randomBytes(32).toString('hex')
|
||||
try {
|
||||
fs.writeFileSync(keyPath, newKey, { encoding: 'utf-8', mode: 0o600 })
|
||||
console.log('[DeviceKey] Generated new device key')
|
||||
} catch (error) {
|
||||
console.error('[DeviceKey] Failed to write device key file:', error)
|
||||
}
|
||||
|
||||
cachedDeviceKey = newKey
|
||||
return cachedDeviceKey
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置缓存(仅测试用)
|
||||
*/
|
||||
export function resetDeviceKeyCache(): void {
|
||||
cachedDeviceKey = null
|
||||
}
|
||||
Reference in New Issue
Block a user