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
This commit is contained in:
iven
2026-05-22 00:13:37 +08:00
parent 29543ef0e7
commit 21f8040994
7 changed files with 329 additions and 115 deletions

View File

@@ -0,0 +1,34 @@
/**
* 微信小程序 crypto.getRandomValues polyfill
* 使用 wx.getRandomValuesSync不可用时回退到 Math.random
* 必须在 app.tsx 首行导入
*/
declare const wx: {
getRandomValuesSync?: (params: { length: number }) => ArrayBuffer;
} | undefined;
if (typeof globalThis.crypto === 'undefined' || !globalThis.crypto.getRandomValues) {
const wxCrypto = {
getRandomValues<T extends ArrayBufferView>(arr: T): T {
if (typeof wx !== 'undefined' && typeof wx.getRandomValuesSync === 'function') {
const buf = wx.getRandomValuesSync({ length: arr.byteLength });
const view = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
const src = new Uint8Array(buf as ArrayBuffer);
for (let i = 0; i < view.length; i++) {
view[i] = src[i] ?? (Math.random() * 256) | 0;
}
return arr;
}
// fallback: Math.random
const view = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
for (let i = 0; i < view.length; i++) {
view[i] = (Math.random() * 256) | 0;
}
return arr;
},
};
// @ts-expect-error — polyfill
globalThis.crypto = wxCrypto;
}

View File

@@ -0,0 +1,44 @@
type LogLevel = 'info' | 'warn' | 'error';
const IS_DEV = process.env.NODE_ENV === 'development';
function formatMessage(level: LogLevel, module: string, message: string): string {
return `[${level.toUpperCase()}][${module}] ${message}`;
}
function safeDetail(detail: unknown): string {
if (IS_DEV) {
return typeof detail === 'object' ? JSON.stringify(detail) : String(detail);
}
return '';
}
export const logger = {
info(module: string, message: string, detail?: unknown): void {
if (IS_DEV) {
console.log(formatMessage('info', module, message), detail ?? '');
}
},
warn(module: string, message: string, detail?: unknown): void {
console.warn(formatMessage('warn', module, message), safeDetail(detail));
},
error(module: string, message: string, detail?: unknown): void {
console.error(formatMessage('error', module, message), safeDetail(detail));
},
};
export function safeWarn(module: string, message: string): void {
if (IS_DEV) {
console.warn(`[${module}] ${message}`);
}
}
export function safeError(module: string, message: string, detail?: unknown): void {
if (IS_DEV) {
console.error(`[${module}] ${message}`, detail ?? '');
} else {
console.error(`[${module}] ${message}`);
}
}

View File

@@ -0,0 +1,167 @@
/**
* 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
}
}

View File

@@ -1,97 +1,5 @@
import Taro from '@tarojs/taro';
const ENCRYPTION_KEY = process.env.TARO_APP_ENCRYPTION_KEY || 'hms-default-key';
function xorEncrypt(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 toBase64(str: string): string {
try {
const encoder = new TextEncoder();
const uint8 = encoder.encode(str);
return Taro.arrayBufferToBase64(uint8.buffer as ArrayBuffer);
} catch {
return '';
}
}
function fromBase64(b64: string): string {
try {
const buffer = Taro.base64ToArrayBuffer(b64);
const decoder = new TextDecoder();
return decoder.decode(new Uint8Array(buffer));
} catch {
return '';
}
}
const STORAGE_PREFIX = '_es_';
export function secureSet(key: string, value: string): void {
if (!value) {
Taro.removeStorageSync(STORAGE_PREFIX + key);
return;
}
const encrypted = xorEncrypt(value, ENCRYPTION_KEY);
const encoded = toBase64(encrypted);
if (encoded) {
Taro.setStorageSync(STORAGE_PREFIX + key, encoded);
} else {
Taro.setStorageSync(STORAGE_PREFIX + key, value);
}
}
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 : '';
}
// 始终尝试 base64 解码 + XOR 解密secureSet 的写入格式)
try {
const decoded = fromBase64(raw);
if (decoded) {
return xorEncrypt(decoded, ENCRYPTION_KEY);
}
} catch {
// fallthrough — 可能是未加密的旧数据
}
// fallback: 兼容未加密的旧数据(明文 JSON/JWT 或其他值)
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) continue;
const legacy = Taro.getStorageSync(key);
if (!legacy || typeof legacy !== 'string') continue;
secureSet(key, legacy);
Taro.removeStorageSync(key);
}
} catch {
// migration best-effort
}
}
/**
* 安全存储 — AES-256-GCM 加密
* 重导出 secure-storage-aes保留旧导入路径兼容
*/
export { secureSet, secureGet, secureRemove, migrateLegacyStorage } from './secure-storage-aes';