fix(mp): 安全修复 + 健康Tab重构为总览

Phase 0 安全修复:
- 移除 secure-storage-aes.ts 硬编码 'hms-default-key' fallback
- production 模式空密钥时拒绝加解密(返回空/不加密)
- dev 模式保留明文兼容(warn 日志提醒)
- .env/.env.h5 注入随机加密密钥
- secureGet 明文 fallback 按环境分级处理
- 新增 8 个测试覆盖空密钥 dev/production 行为

Phase 1 健康Tab重构:
- health/index.tsx 从体征录入页改为健康总览Dashboard
- 新增今日体征摘要卡片(2x2 网格 + 状态标签)
- 新增快捷入口(录入体征/趋势/报告/用药)
- 新增告警提示卡片(待处理告警数量)
- 体征录入移至 pkg-health/input/index(已有页面)
- useHealthData → useHealthOverview(新增 alertCount)

首页增强:
- useHomeData 新增告警计数查询(listPatientAlerts)
- 首页新增告警提示卡片入口
- "记录体征"按钮改为跳转录入页而非健康Tab
This commit is contained in:
iven
2026-05-22 11:48:57 +08:00
parent 490ae075b7
commit d24aefe750
9 changed files with 905 additions and 383 deletions

View File

@@ -13,18 +13,22 @@ declare const wx: {
getRandomValuesSync?: (params: { length: number }) => ArrayBuffer;
} | undefined;
function getEncryptionKey(): Uint8Array {
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)));
}
// 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);
if (process.env.NODE_ENV === 'production') {
console.error('[secure-storage] ENCRYPTION_KEY not configured in production');
return null;
}
return bytes;
// 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 {
@@ -43,13 +47,13 @@ function generateNonce(): Uint8Array {
return nonce;
}
function aesEncrypt(plaintext: string): string {
function aesEncrypt(plaintext: string): string | null {
const key = getEncryptionKey();
if (!key) return null;
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);
@@ -58,6 +62,8 @@ function aesEncrypt(plaintext: string): string {
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);
@@ -65,7 +71,6 @@ function aesDecrypt(encoded: string): string | 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);
@@ -74,15 +79,6 @@ function aesDecrypt(encoded: string): string | 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);
@@ -92,43 +88,59 @@ function fromBase64(b64: string): string {
}
}
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);
if (encrypted) {
Taro.setStorageSync(STORAGE_PREFIX + key, encrypted);
} else {
// 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 注入)
// fallback: 明文键(兼容 MCP 注入,仅 dev
const plain = Taro.getStorageSync(key);
return plain && typeof plain === 'string' ? plain : '';
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;
}
// XOR 格式legacy
try {
const decoded = fromBase64(raw);
if (decoded) {
return xorDecrypt(decoded, LEGACY_KEY);
// AES 解密失败
if (process.env.NODE_ENV === 'production') {
console.warn('[secure-storage] AES decrypt failed in production for key:', key);
return '';
}
} catch {
// fallthrough
// dev: fallthrough to try other formats
}
// 明文 fallback
// 非加密前缀 — 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;
}