import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto" // 落库前加密用户填的第三方 API key。 // // 为什么不明文存:数据库备份、只读账号、误导出的 SQL,任何一处泄漏都等于泄漏所有人的 key。 // 加密后这些渠道拿到的只是密文,解密需要 BETTER_AUTH_SECRET(只在服务端环境变量里)。 // // 方案:AES-256-GCM(带认证标签,篡改会解密失败,不会静默返回垃圾)。 // 密钥用 HKDF 从 BETTER_AUTH_SECRET 派生 —— 不直接拿它当 AES key,免得同一个秘密 // 被会话签名和数据加密两用(一处出问题不牵连另一处)。 // // ⚠️ 换掉 BETTER_AUTH_SECRET 后,已存的密钥解不开(会话也一样会失效)—— // 用户到设置页重填一次即可,我们不做旧密钥回退。 const VERSION = "v1" const IV_BYTES = 12 // GCM 标准 nonce 长度 const KEY_BYTES = 32 // AES-256 const HKDF_SALT = "opentv:provider-credential" const HKDF_INFO = "aes-256-gcm" function encryptionKey(): Buffer { const secret = process.env.BETTER_AUTH_SECRET if (!secret) { throw new Error("缺少 BETTER_AUTH_SECRET,无法加解密服务密钥") } return Buffer.from(hkdfSync("sha256", secret, HKDF_SALT, HKDF_INFO, KEY_BYTES)) } /// 明文 → `v1..<认证标签>.<密文>`(三段都是 base64url) export function encryptSecret(plain: string): string { if (!plain) throw new Error("待加密的内容为空") const iv = randomBytes(IV_BYTES) const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv) const encrypted = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]) return [ VERSION, iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), encrypted.toString("base64url"), ].join(".") } /// 密文 → 明文。格式不对、密钥不对、内容被改过都会抛错(GCM 认证失败) export function decryptSecret(token: string): string { const parts = token.split(".") if (parts.length !== 4 || parts[0] !== VERSION) { throw new Error("密文格式不正确(可能是旧格式或被截断)") } const [, ivPart, tagPart, dataPart] = parts const decipher = createDecipheriv( "aes-256-gcm", encryptionKey(), Buffer.from(ivPart, "base64url"), ) decipher.setAuthTag(Buffer.from(tagPart, "base64url")) return Buffer.concat([ decipher.update(Buffer.from(dataPart, "base64url")), decipher.final(), ]).toString("utf8") } /// 页面回显用的掩码:只露尾 4 位。不足 4 位就全部打码,别把短 key 露光 export function secretHint(plain: string): string { const tail = plain.trim().slice(-4) return tail.length === 4 ? tail : "" }