Files
hms/docs/discussions/2026-04-26-pii-encryption-audit.md
iven 7ab57ea1b2
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix(health): PII 加密安全审计修复 — 2 Critical + 6 Medium + 4 Low
审计发现 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
2026-04-26 13:34:25 +08:00

4.7 KiB
Raw Blame History

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_doctorupdate_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_hashphone_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 字段
  • 详情接口解密+脱敏
  • 审计日志存密文(安全最佳实践)
  • 错误响应无敏感信息泄露