3c9b65d982
- Nuxt 4 + Nuxt UI 4 + AI SDK 7 + Drizzle/SQLite - 主编规划大纲与章节简报、选派作家;作家流式写入章节文件并实时推送前端 - 逐章确认模式(默认)与自动模式;摘要与故事记忆 - 酒馆角色卡(PNG/JSON,V1-V3)与世界书导入 - 自定义最高优先级系统提示词、审稿/修改/多版本、任务日志与提示词检视 - 模拟 LLM 服务与端到端测试脚本 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
50 lines
1.9 KiB
TypeScript
50 lines
1.9 KiB
TypeScript
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||
import { join } from 'node:path'
|
||
|
||
let _key: Buffer | null = null
|
||
|
||
function getKey() {
|
||
if (_key) return _key
|
||
const configured = process.env.NOVEL_SECRET_KEY || useRuntimeConfig().secretKey
|
||
if (configured) {
|
||
const buf = Buffer.from(configured, 'hex')
|
||
if (buf.length !== 32) throw new Error('NOVEL_SECRET_KEY 必须是 64 位十六进制字符串(32 字节)')
|
||
_key = buf
|
||
return _key
|
||
}
|
||
const file = join(dataDir(), 'secret.key')
|
||
if (!existsSync(file)) writeFileSync(file, randomBytes(32).toString('hex'), { mode: 0o600 })
|
||
_key = Buffer.from(readFileSync(file, 'utf8').trim(), 'hex')
|
||
return _key
|
||
}
|
||
|
||
/** AES-256-GCM 加密,输出 iv.tag.ciphertext(base64) */
|
||
export function encryptSecret(plain: string) {
|
||
if (!plain) return ''
|
||
const iv = randomBytes(12)
|
||
const cipher = createCipheriv('aes-256-gcm', getKey(), iv)
|
||
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])
|
||
return [iv, cipher.getAuthTag(), enc].map(b => b.toString('base64')).join('.')
|
||
}
|
||
|
||
export function decryptSecret(payload: string) {
|
||
if (!payload) return ''
|
||
const [iv, tag, enc] = payload.split('.').map(s => Buffer.from(s, 'base64'))
|
||
if (!iv || !tag || !enc) throw new Error('密钥数据损坏')
|
||
try {
|
||
const decipher = createDecipheriv('aes-256-gcm', getKey(), iv)
|
||
decipher.setAuthTag(tag)
|
||
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8')
|
||
}
|
||
catch {
|
||
throw createError({ statusCode: 400, message: 'API Key 无法解密:加密密钥(data/secret.key 或 NOVEL_SECRET_KEY)已变更。请在「模型与 API」中重新填写该提供方的 API Key。' })
|
||
}
|
||
}
|
||
|
||
export function maskSecret(plain: string) {
|
||
if (!plain) return ''
|
||
if (plain.length <= 8) return '••••'
|
||
return `${plain.slice(0, 3)}••••${plain.slice(-4)}`
|
||
}
|