| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281 |
- /**
- * API 密钥加密工具(基于设备指纹 + Web Crypto API)
- *
- * 方案A:设备标识绑定
- * - 密钥派生:设备指纹(机器名 + 用户名 + OS)经过 SHA-256 派生
- * - 加密算法:AES-256-GCM(带认证标签,防篡改)
- * - 存储格式:enc::v1::<base64(nonce + ciphertext + tag)>
- */
- import { invoke } from "@tauri-apps/api/core"
- import { isTauri } from "@/lib/platform"
- const ENCRYPTED_PREFIX = "enc::v1::"
- // 缓存设备密钥,避免每次都重新派生
- let cachedKey: CryptoKey | null = null
- let cachedFingerprint: string | null = null
- /**
- * 获取设备指纹(从 Rust 后端获取)
- *
- * 注意:仅在 Tauri 环境下调用后端命令;
- * 非 Tauri 环境(如纯浏览器/HTTP 模式)降级到 localStorage 随机指纹。
- * 降级指纹不缓存,确保后续 Tauri 就绪后可重新获取。
- */
- export async function getDeviceFingerprint(): Promise<string> {
- // Tauri 环境下尝试从后端获取
- if (isTauri()) {
- if (cachedFingerprint) return cachedFingerprint
- try {
- const fp = await invoke<string>("get_device_fingerprint_cmd")
- if (fp && fp.length >= 64) {
- cachedFingerprint = fp
- return cachedFingerprint
- }
- console.warn("[crypto] 设备指纹长度不足,期望≥64字符,实际:", fp?.length)
- } catch (e) {
- console.warn("[crypto] 获取设备指纹失败,使用降级方案:", e)
- }
- }
- // 降级:使用 localStorage 存储一个随机指纹(不绑定设备,但至少加密存储)
- let fallback = localStorage.getItem("qmai_fallback_fingerprint")
- if (!fallback) {
- const buf = new Uint8Array(32)
- crypto.getRandomValues(buf)
- fallback = Array.from(buf, b => b.toString(16).padStart(2, "0")).join("")
- localStorage.setItem("qmai_fallback_fingerprint", fallback)
- }
- // 注意:降级指纹不缓存到 cachedFingerprint,确保下次 Tauri 就绪后可重试
- return fallback
- }
- /**
- * 从设备指纹派生 AES-256 密钥
- */
- async function getDeviceKey(): Promise<CryptoKey> {
- if (cachedKey) return cachedKey
- const fingerprint = await getDeviceFingerprint()
- // 将指纹 hex 字符串转为字节,验证长度防止 NaN
- const keyMaterial = new Uint8Array(32)
- for (let i = 0; i < 32; i++) {
- const hexByte = fingerprint.slice(i * 2, i * 2 + 2)
- const parsed = parseInt(hexByte, 16)
- if (Number.isNaN(parsed)) {
- throw new Error(`设备指纹格式无效: 位置 ${i * 2} 处不是有效的十六进制字符`)
- }
- keyMaterial[i] = parsed
- }
- const key = await crypto.subtle.importKey(
- "raw",
- keyMaterial,
- { name: "AES-GCM", length: 256 },
- false,
- ["encrypt", "decrypt"],
- )
- cachedKey = key
- return key
- }
- /**
- * 判断字符串是否是加密格式
- */
- export function isEncrypted(value: string): boolean {
- return typeof value === "string" && value.startsWith(ENCRYPTED_PREFIX)
- }
- /**
- * 使用设备密钥加密字符串
- * @param plaintext 明文
- * @returns 加密后的字符串(带前缀)
- */
- export async function encryptString(plaintext: string): Promise<string> {
- if (!plaintext) return plaintext
- if (isEncrypted(plaintext)) return plaintext // 已经是加密的,不重复加密
- const key = await getDeviceKey()
- // 生成 12 字节随机 nonce(GCM 推荐)
- const nonce = new Uint8Array(12)
- crypto.getRandomValues(nonce)
- const encoded = new TextEncoder().encode(plaintext)
- const ciphertext = await crypto.subtle.encrypt(
- { name: "AES-GCM", iv: nonce },
- key,
- encoded,
- )
- // 组合:nonce(12) + ciphertext+tag
- const combined = new Uint8Array(nonce.length + ciphertext.byteLength)
- combined.set(nonce, 0)
- combined.set(new Uint8Array(ciphertext), nonce.length)
- // Base64 编码(使用循环避免 spread 操作符在大数据时栈溢出)
- let binary = ""
- for (let i = 0; i < combined.length; i++) {
- binary += String.fromCharCode(combined[i])
- }
- const b64 = btoa(binary)
- return ENCRYPTED_PREFIX + b64
- }
- /**
- * 使用设备密钥解密字符串
- * @param ciphertext 加密后的字符串(带前缀)
- * @returns 明文
- */
- export async function decryptString(ciphertext: string): Promise<string> {
- if (!ciphertext) return ciphertext
- if (!isEncrypted(ciphertext)) {
- // 未加密的明文,直接返回(向后兼容)
- return ciphertext
- }
- const data = ciphertext.slice(ENCRYPTED_PREFIX.length)
- const binary = Uint8Array.from(atob(data), c => c.charCodeAt(0))
- if (binary.length < 12) {
- throw new Error("密文数据过短")
- }
- const key = await getDeviceKey()
- const nonce = binary.slice(0, 12)
- const cipherData = binary.slice(12)
- const plaintext = await crypto.subtle.decrypt(
- { name: "AES-GCM", iv: nonce },
- key,
- cipherData,
- )
- return new TextDecoder().decode(plaintext)
- }
- /**
- * 加密 API 密钥(带错误处理)
- * 加密失败时抛出错误,让上层决定如何处理(而非静默返回明文)
- */
- export async function safeEncryptApiKey(plaintext: string): Promise<string> {
- try {
- return await encryptString(plaintext)
- } catch (e) {
- console.error("[crypto] 加密失败,拒绝返回明文:", e)
- throw new Error(`API 密钥加密失败: ${e instanceof Error ? e.message : String(e)}`)
- }
- }
- /**
- * 解密 API 密钥(带错误处理,失败则返回原值)
- */
- export async function safeDecryptApiKey(ciphertext: string): Promise<string> {
- try {
- return await decryptString(ciphertext)
- } catch (e) {
- console.warn("[crypto] 解密失败,可能密钥不匹配:", e)
- // 解密失败,可能是换设备了,返回空让用户重新输入
- return ""
- }
- }
- // ── 对象级递归加解密工具 ──────────────────────────────────────────────
- const API_KEY_FIELD_PATTERN = /api[_-]?key/i
- /**
- * 递归加密对象中所有 API key 字段
- * 匹配字段名:apiKey, api_key, api-key, API_KEY 等
- */
- export async function encryptApiKeysInObject<T = unknown>(obj: T): Promise<T> {
- if (obj === null || obj === undefined) return obj
- if (typeof obj === "string") return obj
- if (typeof obj !== "object") return obj
- // 数组
- if (Array.isArray(obj)) {
- const results = await Promise.all(obj.map(item => encryptApiKeysInObject(item)))
- return results as unknown as T
- }
- // 普通对象
- const result: Record<string, unknown> = { ...(obj as Record<string, unknown>) }
- for (const [key, value] of Object.entries(result)) {
- if (API_KEY_FIELD_PATTERN.test(key) && typeof value === "string" && value) {
- result[key] = await safeEncryptApiKey(value)
- } else if (typeof value === "object" && value !== null) {
- result[key] = await encryptApiKeysInObject(value)
- }
- }
- return result as T
- }
- /**
- * 递归解密对象中所有 API key 字段
- */
- export async function decryptApiKeysInObject<T = unknown>(obj: T): Promise<T> {
- if (obj === null || obj === undefined) return obj
- if (typeof obj === "string") return obj
- if (typeof obj !== "object") return obj
- // 数组
- if (Array.isArray(obj)) {
- const results = await Promise.all(obj.map(item => decryptApiKeysInObject(item)))
- return results as unknown as T
- }
- // 普通对象
- const result: Record<string, unknown> = { ...(obj as Record<string, unknown>) }
- for (const [key, value] of Object.entries(result)) {
- if (API_KEY_FIELD_PATTERN.test(key) && typeof value === "string" && value) {
- result[key] = await safeDecryptApiKey(value)
- } else if (typeof value === "object" && value !== null) {
- result[key] = await decryptApiKeysInObject(value)
- }
- }
- return result as T
- }
- // ── 加密状态统计与迁移 ────────────────────────────────────────────────
- /**
- * 统计对象中有多少 API key 字段是明文/已加密的
- */
- export function countApiKeyStatus(obj: unknown): { total: number; encrypted: number; plaintext: number } {
- let total = 0
- let encrypted = 0
- let plaintext = 0
- function walk(value: unknown) {
- if (value === null || value === undefined) return
- if (typeof value === "string") return
- if (typeof value !== "object") return
- if (Array.isArray(value)) {
- value.forEach(walk)
- return
- }
- for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
- if (API_KEY_FIELD_PATTERN.test(key) && typeof val === "string") {
- if (val) {
- total++
- if (isEncrypted(val)) {
- encrypted++
- } else {
- plaintext++
- }
- }
- } else if (typeof val === "object" && val !== null) {
- walk(val)
- }
- }
- }
- walk(obj)
- return { total, encrypted, plaintext }
- }
|