/** * AES-256-GCM 加密存储 — 替代 XOR 加密 * 依赖 @noble/ciphers + crypto-polyfill(wx.getRandomValuesSync) */ import Taro from '@tarojs/taro'; import { gcm } from '@noble/ciphers/aes.js'; const STORAGE_PREFIX = '_es_'; const AES_MARKER = 'aes:'; const NONCE_LENGTH = 12; // GCM 标准 nonce 长度 declare const wx: { getRandomValuesSync?: (params: { length: number }) => ArrayBuffer; } | undefined; function getEncryptionKey(): Uint8Array { const hex = process.env.TARO_APP_ENCRYPTION_KEY || ''; if (hex && /^[0-9a-fA-F]{64}$/.test(hex)) { return new Uint8Array(hex.match(/.{2}/g)!.map((b) => parseInt(b, 16))); } // derive 32 bytes from passphrase const passphrase = process.env.TARO_APP_ENCRYPTION_KEY || 'hms-default-key'; const bytes = new Uint8Array(32); for (let i = 0; i < 32; i++) { bytes[i] = passphrase.charCodeAt(i % passphrase.length) ^ ((i * 37) & 0xff); } return bytes; } function generateNonce(): Uint8Array { const nonce = new Uint8Array(NONCE_LENGTH); if (typeof globalThis.crypto?.getRandomValues === 'function') { globalThis.crypto.getRandomValues(nonce); } else if (typeof wx !== 'undefined' && typeof wx.getRandomValuesSync === 'function') { const buf = wx.getRandomValuesSync({ length: NONCE_LENGTH }); const src = new Uint8Array(buf as ArrayBuffer); nonce.set(src); } else { for (let i = 0; i < NONCE_LENGTH; i++) { nonce[i] = (Math.random() * 256) | 0; } } return nonce; } function aesEncrypt(plaintext: string): string { const key = getEncryptionKey(); const nonce = generateNonce(); const cipher = gcm(key, nonce); const data = new TextEncoder().encode(plaintext); const ciphertext = cipher.encrypt(data); // nonce(12) + ciphertext 打包 const combined = new Uint8Array(nonce.length + ciphertext.length); combined.set(nonce, 0); combined.set(ciphertext, nonce.length); return AES_MARKER + Taro.arrayBufferToBase64(combined.buffer as ArrayBuffer); } function aesDecrypt(encoded: string): string | null { try { const b64 = encoded.slice(AES_MARKER.length); const buf = Taro.base64ToArrayBuffer(b64); const combined = new Uint8Array(buf); if (combined.length < NONCE_LENGTH) return null; const nonce = combined.slice(0, NONCE_LENGTH); const ciphertext = combined.slice(NONCE_LENGTH); const key = getEncryptionKey(); const cipher = gcm(key, nonce); const plaintext = cipher.decrypt(ciphertext); return new TextDecoder().decode(plaintext); } catch { return null; } } // XOR decryption for reading legacy data function xorDecrypt(data: string, key: string): string { let result = ''; for (let i = 0; i < data.length; i++) { result += String.fromCharCode(data.charCodeAt(i) ^ key.charCodeAt(i % key.length)); } return result; } function fromBase64(b64: string): string { try { const buffer = Taro.base64ToArrayBuffer(b64); return new TextDecoder().decode(new Uint8Array(buffer)); } catch { return ''; } } const LEGACY_KEY = process.env.TARO_APP_ENCRYPTION_KEY || 'hms-default-key'; export function secureSet(key: string, value: string): void { if (!value) { Taro.removeStorageSync(STORAGE_PREFIX + key); return; } const encrypted = aesEncrypt(value); Taro.setStorageSync(STORAGE_PREFIX + key, encrypted); } export function secureGet(key: string): string { const prefixedKey = STORAGE_PREFIX + key; const raw = Taro.getStorageSync(prefixedKey); if (!raw || typeof raw !== 'string') { // fallback: 明文键(兼容 MCP 注入) const plain = Taro.getStorageSync(key); return plain && typeof plain === 'string' ? plain : ''; } // AES 格式 if (raw.startsWith(AES_MARKER)) { const decrypted = aesDecrypt(raw); if (decrypted !== null) return decrypted; } // XOR 格式(legacy) try { const decoded = fromBase64(raw); if (decoded) { return xorDecrypt(decoded, LEGACY_KEY); } } catch { // fallthrough } // 明文 fallback return raw; } export function secureRemove(key: string): void { Taro.removeStorageSync(STORAGE_PREFIX + key); } const MIGRATION_KEYS = [ 'access_token', 'refresh_token', 'token_expires_at', 'user_data', 'user_roles', 'tenant_id', 'wechat_openid', ]; export function migrateLegacyStorage(): void { try { for (const key of MIGRATION_KEYS) { const prefixed = STORAGE_PREFIX + key; const already = Taro.getStorageSync(prefixed); if (already) { // re-encrypt with AES if not already if (typeof already === 'string' && !already.startsWith(AES_MARKER)) { const value = secureGet(key); if (value) secureSet(key, value); } continue; } const legacy = Taro.getStorageSync(key); if (!legacy || typeof legacy !== 'string') continue; secureSet(key, legacy); Taro.removeStorageSync(key); } } catch { // migration best-effort } }