审计发现 55 检查点,46 PASS / 7 WARN / 2 FAIL,修复内容: Critical: - C1: 密钥轮换端点现在持久化新 DEK 到 tenant_crypto_keys 表 - C2: CachedDek 实现 Drop trait,释放时清零密钥材料 Medium: - M1: 密文格式添加版本前缀 0x01,向后兼容旧格式 - M2: HMAC 索引使用独立子密钥,与加密 KEK 分离 - M4: 脱敏函数使用 chars() 迭代器,UTF-8 安全 - M5-M6: 医生执业证号详情响应脱敏 (mask_license_number) Low: - L1: dek_manager 改为 pub(crate),暴露 invalidate_dek() 方法 - L3: 合并 patient 列表搜索中冗余的重复 HMAC 计算 - L4: update_family_member/update_doctor 更新时设置 key_version
4.7 KiB
4.7 KiB
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::<String>()
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 字段 ✅
- 详情接口解密+脱敏 ✅
- 审计日志存密文(安全最佳实践)✅
- 错误响应无敏感信息泄露 ✅