- 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
168 lines
4.9 KiB
TypeScript
168 lines
4.9 KiB
TypeScript
/**
|
||
* 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
|
||
}
|
||
}
|