# PII 加密功能穷尽审计报告 > 日期: 2026-04-26 | 审计范围: erp-core/crypto + erp-health 全 Entity + API 响应 ## 审计概要 | 维度 | 检查点数 | PASS | WARN | FAIL | |------|---------|------|------|------| | 加密引擎 | 5 | 4 | 0 | 1 | | HMAC/搜索索引 | 2 | 1 | 1 | 0 | | 密钥管理 | 5 | 2 | 2 | 1 | | 脱敏函数 | 3 | 1 | 2 | 0 | | 字段覆盖 (8 Entity) | 16 字段 | 16 | 0 | 0 | | 搜索适配 | 6 | 6 | 0 | 0 | | 响应泄露 | 17 | 15 | 2 | 0 | | 错误处理 | 1 | 1 | 0 | 0 | | **总计** | **55** | **46** | **7** | **2** | ## Critical — 立即修复 ### C1: 密钥轮换端点未持久化新 DEK - **文件**: `crates/erp-server/src/handlers/crypto_admin.rs:27-28` - **问题**: `rotate_tenant_key` 生成新 DEK 后丢弃(`_new_dek`, `_encrypted_dek`),版本号硬编码为 1 - **影响**: 调用轮换 API 后缓存清空,但 DB 中无新 DEK,下次 `get_or_create_dek` 生成全新 DEK,导致旧数据不可解密 - **修复**: 将 encrypted_dek 写入 `tenant_crypto_keys` 表,版本 = max(version) + 1 ### C2: DEK 密钥材料在内存中未 zeroize - **文件**: `crates/erp-core/src/crypto/key_manager.rs:12-16` - **问题**: `CachedDek.dek: [u8; 32]` 无 `Drop` 实现,过期后密钥残留在堆内存 - **影响**: crash dump 或内存扫描可能泄露密钥材料 - **修复**: 添加 `impl Drop for CachedDek { fn drop(&mut self) { self.dek.zeroize(); } }` ## Medium — 本迭代修复 ### M1: 密文缺少版本标识符 - **文件**: `crates/erp-core/src/crypto/engine.rs` - **问题**: 密文格式为裸 `Base64(nonce || ciphertext)`,无算法版本前缀 - **影响**: 未来从 AES-256-GCM 迁移到其他算法时,无法区分新旧密文 - **修复**: 输出格式改为 `Base64(0x01 || nonce || ciphertext)` ### M2: HMAC 和加密共用 KEK - **文件**: `crates/erp-core/src/crypto/hmac_index.rs` + 调用处 - **问题**: HMAC 索引和 AES 加密使用同一密钥,违反密钥分离原则 - **影响**: KEK 泄露后攻击者可同时解密和伪造搜索索引 - **修复**: 派生独立 HMAC 子密钥 `hmac_key = HKDF(kek, "pii-hmac-v1")` ### M3: DEK 版本号硬编码为 1 - **文件**: `crates/erp-core/src/crypto/key_manager.rs:64,76` - **问题**: 版本始终为 1,轮换功能不完整 - **影响**: 无法按版本解密旧数据 - **修复**: 从 `tenant_crypto_keys` 表读取当前版本 ### M4: 脱敏函数字节切片对 UTF-8 不安全 - **文件**: `crates/erp-core/src/crypto/masking.rs:3,13` - **问题**: `&s[..3]` 和 `&s[s.len()-4..]` 按字节切片,多字节 UTF-8 字符可能 panic - **影响**: 国际化数据传入时服务崩溃 - **修复**: 改用 `.chars().take(3).collect::()` ### M5: doctor license_number 详情返回明文未脱敏 - **文件**: `crates/erp-health/src/service/doctor_service.rs:243-244` - **问题**: `model_to_resp_decrypted` 解密后返回完整执照号,对比 patient 的 id_number 做了 mask - **影响**: Tier 1 敏感字段通过 API 完整暴露 - **修复**: 添加 `mask_license_number` 脱敏函数 ### M6: doctor create/update 返回 license_number 明文 - **同 M5**: `create_doctor` 和 `update_doctor` 均使用 `model_to_resp_decrypted` ## Low — 下迭代改进 ### L1: dek_manager 字段可见性 - **文件**: `crates/erp-core/src/crypto/mod.rs:18` - **问题**: `pub dek_manager` 暴露内部缓存操作 - **建议**: 改为 `pub(crate)` 或提供受控公共方法 ### L2: DEK 缓存无后台清理 - **文件**: `crates/erp-core/src/crypto/key_manager.rs` - **问题**: 过期 DEK 仅在访问时评估,无后台 zeroize - **建议**: 添加 tokio 定时任务定期扫描并清理过期条目 ### L3: 冗余 HMAC 计算 - **文件**: `crates/erp-health/src/service/patient_service.rs:61-62` - **问题**: `search_hash` 和 `phone_hash` 对同一输入计算两次相同 HMAC - **修复**: 合并为一个变量 ### L4: update_family_member / update_doctor 未显式重设 key_version - **文件**: patient_service.rs L584-641, doctor_service.rs L132-181 - **问题**: UPDATE 路径未显式 `Set(Some(1))`,依赖 ActiveModel 保留的原始值 - **风险**: 功能上无影响,但风格不一致,密钥轮换后需修改 ## PASS 项摘要 - AES-256-GCM nonce 使用 CSPRNG ✅ - 密文格式正确包含 nonce(可分离)✅ - 密钥长度编译期强制 32 字节 ✅ - GCM auth tag 完整性校验 ✅ - KEK 通过环境变量注入,生产构建拒绝 dev_default ✅ - 所有 8 个 Entity 的 16 个 PII 字段全覆盖(15 写路径 + 20 读路径)✅ - 搜索适配正确(HMAC 精确替代 SQL LIKE)✅ - 列表接口隐藏 Tier 1 字段 ✅ - 详情接口解密+脱敏 ✅ - 审计日志存密文(安全最佳实践)✅ - 错误响应无敏感信息泄露 ✅