Files
hms/apps/miniprogram/src/utils/secure-storage-aes.ts
iven 675f8a4b10 fix(mp): 小程序真机 TextEncoder 不可用 + DevTools getPhoneNumber 绕过
- secure-storage-aes.ts 用纯 JS 实现 UTF-8 编解码替代 TextEncoder/TextDecoder
- 登录页绑定手机号步骤:DevTools/模拟器中跳过微信 SDK 直接调后端 mock
2026-05-23 13:00:05 +08:00

229 lines
7.1 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;
// 小程序环境无 TextEncoder/TextDecoder用纯 JS 替代
function utf8Encode(str: string): Uint8Array {
const bytes: number[] = [];
for (let i = 0; i < str.length; i++) {
let code = str.charCodeAt(i);
if (code < 0x80) {
bytes.push(code);
} else if (code < 0x800) {
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
} else if (code >= 0xd800 && code <= 0xdbff) {
// surrogate pair
const hi = code;
const lo = str.charCodeAt(++i);
code = ((hi - 0xd800) << 10) + (lo - 0xdc00) + 0x10000;
bytes.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
} else {
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
}
}
return new Uint8Array(bytes);
}
function utf8Decode(bytes: Uint8Array): string {
let str = '';
let i = 0;
while (i < bytes.length) {
const b = bytes[i++];
if (b < 0x80) {
str += String.fromCharCode(b);
} else if (b < 0xe0) {
str += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f));
} else if (b < 0xf0) {
str += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f));
} else {
const cp = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f);
const hi = ((cp - 0x10000) >> 10) + 0xd800;
const lo = ((cp - 0x10000) & 0x3ff) + 0xdc00;
str += String.fromCharCode(hi, lo);
}
}
return str;
}
function getEncryptionKey(): Uint8Array | null {
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)));
}
if (process.env.NODE_ENV === 'production') {
console.error('[secure-storage] ENCRYPTION_KEY not configured in production');
return null;
}
// dev: warn and use plaintext-compatible mode
console.warn('[secure-storage] ENCRYPTION_KEY empty — using dev plaintext mode');
return null;
}
function hasEncryptionKey(): boolean {
return getEncryptionKey() !== null;
}
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 | null {
const key = getEncryptionKey();
if (!key) return null;
const nonce = generateNonce();
const cipher = gcm(key, nonce);
const data = utf8Encode(plaintext);
const ciphertext = cipher.encrypt(data);
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 key = getEncryptionKey();
if (!key) return null;
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 cipher = gcm(key, nonce);
const plaintext = cipher.decrypt(ciphertext);
return utf8Decode(plaintext);
} catch {
return null;
}
}
function fromBase64(b64: string): string {
try {
const buffer = Taro.base64ToArrayBuffer(b64);
return new TextDecoder().decode(new Uint8Array(buffer));
} catch {
return '';
}
}
export function secureSet(key: string, value: string): void {
if (!value) {
Taro.removeStorageSync(STORAGE_PREFIX + key);
return;
}
const encrypted = aesEncrypt(value);
if (encrypted) {
Taro.setStorageSync(STORAGE_PREFIX + key, encrypted);
return;
}
// 密钥不可用时的降级策略
if (process.env.NODE_ENV === 'production') {
// 生产环境不允许明文存储敏感数据
console.error(`[secure-storage] 拒绝明文写入 production key: ${key} — ENCRYPTION_KEY 未配置`);
return;
}
// dev mode: store plaintext with prefix for compatibility
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 注入,仅 dev
const plain = Taro.getStorageSync(key);
if (plain && typeof plain === 'string') {
if (process.env.NODE_ENV === 'production') {
console.warn('[secure-storage] plaintext fallback in production for key:', key);
}
return plain;
}
return '';
}
// AES 格式
if (raw.startsWith(AES_MARKER)) {
const decrypted = aesDecrypt(raw);
if (decrypted !== null) return decrypted;
// AES 解密失败
if (process.env.NODE_ENV === 'production') {
console.warn('[secure-storage] AES decrypt failed in production for key:', key);
return '';
}
// dev: fallthrough to try other formats
}
// 非加密前缀 — dev mode plaintext 或 legacy XOR
if (!raw.startsWith(AES_MARKER) && hasEncryptionKey()) {
// key is configured but data isn't AES-encrypted — try legacy XOR
try {
const decoded = fromBase64(raw);
if (decoded) return decoded;
} catch {
// fallthrough
}
}
// dev mode: stored as plaintext by secureSet when no key
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
}
}