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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user