crypto.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. /**
  2. * API 密钥加密工具(基于设备指纹 + Web Crypto API)
  3. *
  4. * 方案A:设备标识绑定
  5. * - 密钥派生:设备指纹(机器名 + 用户名 + OS)经过 SHA-256 派生
  6. * - 加密算法:AES-256-GCM(带认证标签,防篡改)
  7. * - 存储格式:enc::v1::<base64(nonce + ciphertext + tag)>
  8. */
  9. import { invoke } from "@tauri-apps/api/core"
  10. import { isTauri } from "@/lib/platform"
  11. const ENCRYPTED_PREFIX = "enc::v1::"
  12. // 缓存设备密钥,避免每次都重新派生
  13. let cachedKey: CryptoKey | null = null
  14. let cachedFingerprint: string | null = null
  15. /**
  16. * 获取设备指纹(从 Rust 后端获取)
  17. *
  18. * 注意:仅在 Tauri 环境下调用后端命令;
  19. * 非 Tauri 环境(如纯浏览器/HTTP 模式)降级到 localStorage 随机指纹。
  20. * 降级指纹不缓存,确保后续 Tauri 就绪后可重新获取。
  21. */
  22. export async function getDeviceFingerprint(): Promise<string> {
  23. // Tauri 环境下尝试从后端获取
  24. if (isTauri()) {
  25. if (cachedFingerprint) return cachedFingerprint
  26. try {
  27. const fp = await invoke<string>("get_device_fingerprint_cmd")
  28. if (fp && fp.length >= 64) {
  29. cachedFingerprint = fp
  30. return cachedFingerprint
  31. }
  32. console.warn("[crypto] 设备指纹长度不足,期望≥64字符,实际:", fp?.length)
  33. } catch (e) {
  34. console.warn("[crypto] 获取设备指纹失败,使用降级方案:", e)
  35. }
  36. }
  37. // 降级:使用 localStorage 存储一个随机指纹(不绑定设备,但至少加密存储)
  38. let fallback = localStorage.getItem("qmai_fallback_fingerprint")
  39. if (!fallback) {
  40. const buf = new Uint8Array(32)
  41. crypto.getRandomValues(buf)
  42. fallback = Array.from(buf, b => b.toString(16).padStart(2, "0")).join("")
  43. localStorage.setItem("qmai_fallback_fingerprint", fallback)
  44. }
  45. // 注意:降级指纹不缓存到 cachedFingerprint,确保下次 Tauri 就绪后可重试
  46. return fallback
  47. }
  48. /**
  49. * 从设备指纹派生 AES-256 密钥
  50. */
  51. async function getDeviceKey(): Promise<CryptoKey> {
  52. if (cachedKey) return cachedKey
  53. const fingerprint = await getDeviceFingerprint()
  54. // 将指纹 hex 字符串转为字节,验证长度防止 NaN
  55. const keyMaterial = new Uint8Array(32)
  56. for (let i = 0; i < 32; i++) {
  57. const hexByte = fingerprint.slice(i * 2, i * 2 + 2)
  58. const parsed = parseInt(hexByte, 16)
  59. if (Number.isNaN(parsed)) {
  60. throw new Error(`设备指纹格式无效: 位置 ${i * 2} 处不是有效的十六进制字符`)
  61. }
  62. keyMaterial[i] = parsed
  63. }
  64. const key = await crypto.subtle.importKey(
  65. "raw",
  66. keyMaterial,
  67. { name: "AES-GCM", length: 256 },
  68. false,
  69. ["encrypt", "decrypt"],
  70. )
  71. cachedKey = key
  72. return key
  73. }
  74. /**
  75. * 判断字符串是否是加密格式
  76. */
  77. export function isEncrypted(value: string): boolean {
  78. return typeof value === "string" && value.startsWith(ENCRYPTED_PREFIX)
  79. }
  80. /**
  81. * 使用设备密钥加密字符串
  82. * @param plaintext 明文
  83. * @returns 加密后的字符串(带前缀)
  84. */
  85. export async function encryptString(plaintext: string): Promise<string> {
  86. if (!plaintext) return plaintext
  87. if (isEncrypted(plaintext)) return plaintext // 已经是加密的,不重复加密
  88. const key = await getDeviceKey()
  89. // 生成 12 字节随机 nonce(GCM 推荐)
  90. const nonce = new Uint8Array(12)
  91. crypto.getRandomValues(nonce)
  92. const encoded = new TextEncoder().encode(plaintext)
  93. const ciphertext = await crypto.subtle.encrypt(
  94. { name: "AES-GCM", iv: nonce },
  95. key,
  96. encoded,
  97. )
  98. // 组合:nonce(12) + ciphertext+tag
  99. const combined = new Uint8Array(nonce.length + ciphertext.byteLength)
  100. combined.set(nonce, 0)
  101. combined.set(new Uint8Array(ciphertext), nonce.length)
  102. // Base64 编码(使用循环避免 spread 操作符在大数据时栈溢出)
  103. let binary = ""
  104. for (let i = 0; i < combined.length; i++) {
  105. binary += String.fromCharCode(combined[i])
  106. }
  107. const b64 = btoa(binary)
  108. return ENCRYPTED_PREFIX + b64
  109. }
  110. /**
  111. * 使用设备密钥解密字符串
  112. * @param ciphertext 加密后的字符串(带前缀)
  113. * @returns 明文
  114. */
  115. export async function decryptString(ciphertext: string): Promise<string> {
  116. if (!ciphertext) return ciphertext
  117. if (!isEncrypted(ciphertext)) {
  118. // 未加密的明文,直接返回(向后兼容)
  119. return ciphertext
  120. }
  121. const data = ciphertext.slice(ENCRYPTED_PREFIX.length)
  122. const binary = Uint8Array.from(atob(data), c => c.charCodeAt(0))
  123. if (binary.length < 12) {
  124. throw new Error("密文数据过短")
  125. }
  126. const key = await getDeviceKey()
  127. const nonce = binary.slice(0, 12)
  128. const cipherData = binary.slice(12)
  129. const plaintext = await crypto.subtle.decrypt(
  130. { name: "AES-GCM", iv: nonce },
  131. key,
  132. cipherData,
  133. )
  134. return new TextDecoder().decode(plaintext)
  135. }
  136. /**
  137. * 加密 API 密钥(带错误处理)
  138. * 加密失败时抛出错误,让上层决定如何处理(而非静默返回明文)
  139. */
  140. export async function safeEncryptApiKey(plaintext: string): Promise<string> {
  141. try {
  142. return await encryptString(plaintext)
  143. } catch (e) {
  144. console.error("[crypto] 加密失败,拒绝返回明文:", e)
  145. throw new Error(`API 密钥加密失败: ${e instanceof Error ? e.message : String(e)}`)
  146. }
  147. }
  148. /**
  149. * 解密 API 密钥(带错误处理,失败则返回原值)
  150. */
  151. export async function safeDecryptApiKey(ciphertext: string): Promise<string> {
  152. try {
  153. return await decryptString(ciphertext)
  154. } catch (e) {
  155. console.warn("[crypto] 解密失败,可能密钥不匹配:", e)
  156. // 解密失败,可能是换设备了,返回空让用户重新输入
  157. return ""
  158. }
  159. }
  160. // ── 对象级递归加解密工具 ──────────────────────────────────────────────
  161. const API_KEY_FIELD_PATTERN = /api[_-]?key/i
  162. /**
  163. * 递归加密对象中所有 API key 字段
  164. * 匹配字段名:apiKey, api_key, api-key, API_KEY 等
  165. */
  166. export async function encryptApiKeysInObject<T = unknown>(obj: T): Promise<T> {
  167. if (obj === null || obj === undefined) return obj
  168. if (typeof obj === "string") return obj
  169. if (typeof obj !== "object") return obj
  170. // 数组
  171. if (Array.isArray(obj)) {
  172. const results = await Promise.all(obj.map(item => encryptApiKeysInObject(item)))
  173. return results as unknown as T
  174. }
  175. // 普通对象
  176. const result: Record<string, unknown> = { ...(obj as Record<string, unknown>) }
  177. for (const [key, value] of Object.entries(result)) {
  178. if (API_KEY_FIELD_PATTERN.test(key) && typeof value === "string" && value) {
  179. result[key] = await safeEncryptApiKey(value)
  180. } else if (typeof value === "object" && value !== null) {
  181. result[key] = await encryptApiKeysInObject(value)
  182. }
  183. }
  184. return result as T
  185. }
  186. /**
  187. * 递归解密对象中所有 API key 字段
  188. */
  189. export async function decryptApiKeysInObject<T = unknown>(obj: T): Promise<T> {
  190. if (obj === null || obj === undefined) return obj
  191. if (typeof obj === "string") return obj
  192. if (typeof obj !== "object") return obj
  193. // 数组
  194. if (Array.isArray(obj)) {
  195. const results = await Promise.all(obj.map(item => decryptApiKeysInObject(item)))
  196. return results as unknown as T
  197. }
  198. // 普通对象
  199. const result: Record<string, unknown> = { ...(obj as Record<string, unknown>) }
  200. for (const [key, value] of Object.entries(result)) {
  201. if (API_KEY_FIELD_PATTERN.test(key) && typeof value === "string" && value) {
  202. result[key] = await safeDecryptApiKey(value)
  203. } else if (typeof value === "object" && value !== null) {
  204. result[key] = await decryptApiKeysInObject(value)
  205. }
  206. }
  207. return result as T
  208. }
  209. // ── 加密状态统计与迁移 ────────────────────────────────────────────────
  210. /**
  211. * 统计对象中有多少 API key 字段是明文/已加密的
  212. */
  213. export function countApiKeyStatus(obj: unknown): { total: number; encrypted: number; plaintext: number } {
  214. let total = 0
  215. let encrypted = 0
  216. let plaintext = 0
  217. function walk(value: unknown) {
  218. if (value === null || value === undefined) return
  219. if (typeof value === "string") return
  220. if (typeof value !== "object") return
  221. if (Array.isArray(value)) {
  222. value.forEach(walk)
  223. return
  224. }
  225. for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
  226. if (API_KEY_FIELD_PATTERN.test(key) && typeof val === "string") {
  227. if (val) {
  228. total++
  229. if (isEncrypted(val)) {
  230. encrypted++
  231. } else {
  232. plaintext++
  233. }
  234. }
  235. } else if (typeof val === "object" && val !== null) {
  236. walk(val)
  237. }
  238. }
  239. }
  240. walk(obj)
  241. return { total, encrypted, plaintext }
  242. }