Files
hms/apps/miniprogram/src/utils/secure-storage-aes.ts
iven 21f8040994 feat(mp): AES-256-GCM 加密存储 + 安全日志 + ErrorBoundary 升级 + BLE 并发修复
- secure-storage-aes: AES-256-GCM 替代 XOR,保留 XOR 迁移读取
- crypto-polyfill: wx.getRandomValuesSync → crypto.getRandomValues
- logger.ts: dev/prod 区分日志级别,生产不输出详情
- ErrorBoundary: 错误分类(network/render/unknown) + 结构化日志
- DataSyncScheduler: isSyncing 互斥防并发重复同步
- app.tsx 首行导入 crypto-polyfill
2026-05-22 00:13:37 +08:00

168 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AES-256-GCM 加密存储 — 替代 XOR 加密
* 依赖 @noble/ciphers + crypto-polyfillwx.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
}
}