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:
34
apps/miniprogram/src/utils/crypto-polyfill.ts
Normal file
34
apps/miniprogram/src/utils/crypto-polyfill.ts
Normal 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;
|
||||
}
|
||||
44
apps/miniprogram/src/utils/logger.ts
Normal file
44
apps/miniprogram/src/utils/logger.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
167
apps/miniprogram/src/utils/secure-storage-aes.ts
Normal file
167
apps/miniprogram/src/utils/secure-storage-aes.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user