- docs/: 设计规格、讨论记录、销售数据、健康管理文档 - scripts/: 辅助脚本 - package.json + pnpm-lock.yaml: monorepo 根配置
22 KiB
PII 分级加密扩展设计
日期: 2026-04-26 | 状态: 草案 | 作者: iven + Claude
目录
- 背景与动机
- 设计目标
- 当前状态分析
- 分级加密策略
- 加密基础设施提升 (erp-core)
- 每租户独立密钥管理
- Entity 层改造
- Service 层集成
- 数据迁移
- 边界场景处理
- 分步实施计划
- 测试策略
- 风险与缓解
1. 背景与动机
HMS 健康管理平台当前仅加密了 patient.id_number(身份证号)一个字段,使用 AES-256-GCM + HMAC-SHA256 搜索索引。其余 30+ 个 PII 字段(患者姓名、电话、过敏史、病史摘要、咨询内容等)均为明文存储。
驱动力:
- 体检中心/医疗机构在采购前会进行安全审计,明文 PII 是 dealbreaker
- 《个人信息保护法》对医疗健康信息有特殊保护要求
- 多租户 SaaS 架构下,数据隔离需要从访问控制扩展到存储加密
- 三专家评审一致指出 PII 加密范围不足是高优合规风险
约束:
- 系统前期不涉及实际医疗行为,PII 加密是合规门槛而非实时临床安全
- 保持可搜索性:患者姓名是高频搜索字段,不能因加密丧失搜索能力
- 性能可接受:加密/解密开销不能显著影响 API 响应时间
2. 设计目标
| 目标 | 指标 |
|---|---|
| PII 字段加密覆盖 | 从 1 个字段扩展到 15+ 个高风险字段 |
| 多租户密钥隔离 | 每租户独立 DEK,泄漏不影响其他租户 |
| 搜索能力保持 | HMAC 精确搜索 + 姓名明文模糊搜索 |
| 性能影响 | 单条解密 < 0.1ms,列表页不触发解密 |
| 零停机迁移 | 现有明文数据逐步加密,不锁表 |
3. 当前状态分析
已有加密能力
| 组件 | 位置 | 状态 |
|---|---|---|
HealthCrypto |
crates/erp-health/src/crypto.rs |
AES-256-GCM + HMAC-SHA256 |
| 身份证号加密 | patient_service.rs |
已上线 |
| 脱敏管道 | crates/erp-health/src/service/masking.rs |
mask_id_number, mask_phone |
| AI 脱敏层 | crates/erp-ai/src/sanitization/mod.rs |
DTO 去标识化 |
| 密钥配置 | crates/erp-server/config/default.toml |
环境变量注入 |
明文高风险字段清单
患者 (patient) — 8 个未加密 PII 字段:
name— 姓名(决定不加密,保持搜索性)emergency_contact_phone— 紧急联系人电话allergy_history— 过敏史medical_history_summary— 病史摘要emergency_contact_name— 紧急联系人姓名birth_date— 出生日期blood_type— 血型notes— 备注
家庭成员 (patient_family_member) — 2 个:
phone— 电话name— 姓名
咨询消息 (consultation_message) — 1 个:
content— 咨询/问诊内容
随访记录 (follow_up_record) — 3 个:
result— 随访结果patient_condition— 患者状况medical_advice— 医嘱
诊断 (diagnosis) — 1 个:
notes— 诊断备注
医生档案 (doctor_profile) — 1 个:
license_number— 执业证号
透析记录 (dialysis_record) — 2 个:
symptoms— 症状 JSONcomplication_notes— 并发症记录
化验报告 (lab_report) — 1 个:
doctor_notes— 医生备注
4. 分级加密策略
Tier 定义
| 级别 | 存储方式 | 搜索方式 | 展示方式 | 适用字段 |
|---|---|---|---|---|
| Tier 1 | AES-256-GCM 加密 | HMAC-SHA256 精确匹配 | 解密后展示/脱敏 | 高敏感、可枚举的字段 |
| Tier 2 | 明文 | 直接 SQL LIKE/模糊 | API 脱敏展示 | 中敏感、高频搜索的字段 |
| Tier 3 | 明文 | 直接 SQL | 原样展示 | 非敏感或非 PII 字段 |
Tier 1 — 加密存储字段
| Entity | 字段 | HMAC 搜索 |
|---|---|---|
| patient | emergency_contact_phone |
emergency_contact_phone_hash |
| patient | allergy_history |
— |
| patient | medical_history_summary |
— |
| patient_family_member | phone |
phone_hash |
| consultation_message | content |
— |
咨询内容搜索说明: consultation_message.content 加密后无法做全文搜索。替代方案:按咨询会话(consultation)维度搜索和过滤(通过状态、时间、医生等非加密字段),进入详情后查看具体消息内容。如未来需要全文搜索,可引入加密索引(如 Order-Preserving Encryption 或专用密文搜索引擎),但 V1 不实施。
| follow_up_record | result | — |
| follow_up_record | patient_condition | — |
| follow_up_record | medical_advice | — |
| doctor_profile | license_number | license_number_hash |
| diagnosis | notes | — |
| dialysis_record | complication_notes | — |
| dialysis_record | symptoms (JSON) | — |
| lab_report | doctor_notes | — |
JSON 字段加密说明: dialysis_record.symptoms 类型为 serde_json::Value,加密时先序列化为 JSON 字符串再加密,解密后反序列化回 Value。
注: patient.id_number 和 id_number_hash 已存在,无需改动。
Tier 2 — 明文 + API 脱敏
| Entity | 字段 | 脱敏规则 |
|---|---|---|
| patient | emergency_contact_name |
保留首字 + **(如 张**),两字姓名也适用(张*) |
| patient | birth_date |
仅显示年份 |
| patient_family_member | name |
保留首字 + **,同上 |
Tier 3 — 明文
name, gender, blood_type, 数值型体征数据, 日期, 状态字段, icd_code, diagnosis_name, notes(patient 和 health_record 的备注字段,内容不可预测且非临床核心)等。
分类澄清: notes 字段在不同 Entity 中分级不同。patient.notes 归入 Tier 3(一般备注),diagnosis.notes 归入 Tier 1(临床诊断备注),follow_up_record 的 result/patient_condition/medical_advice 归入 Tier 1。具体分级以上面 Tier 1/2/3 表格为准。
设计决策:患者姓名不加密。 原因:医疗场景下姓名不是真正意义上的隐私(前台叫号、医生喊名),保持明文可搜索对运营效率至关重要。加密姓名会强制改变搜索交互模式(从"先搜索"变成"先选择"),代价过大。
5. 加密基础设施提升
5.1 从 HealthCrypto 到 PiiCrypto
将 crates/erp-health/src/crypto.rs 中的 HealthCrypto 提升到 crates/erp-core/src/crypto/:
crates/erp-core/src/crypto/
├── mod.rs # PiiCrypto 公开接口
├── engine.rs # AES-256-GCM 加密/解密引擎
├── hmac.rs # HMAC-SHA256 搜索索引
├── key_manager.rs # 每租户 DEK 管理
└── masking.rs # 脱敏管道(从 health/service/masking.rs 提升)
5.2 PiiCrypto 接口设计
pub struct PiiCrypto {
kek: [u8; 32], // Master KEK
dek_cache: DashMap<Uuid, CachedDek>, // 租户 DEK 缓存
}
struct CachedDek {
dek: [u8; 32],
version: u32,
loaded_at: Instant,
}
impl PiiCrypto {
/// 加密单个字段,返回 Base64(nonce + ciphertext + tag)
pub fn encrypt(&self, dek: &[u8; 32], plaintext: &str) -> Result<String>;
/// 解密单个字段
pub fn decrypt(&self, dek: &[u8; 32], ciphertext: &str) -> Result<String>;
/// 生成 HMAC-SHA256 搜索索引
pub fn hmac_hash(&self, dek: &[u8; 32], value: &str) -> String;
/// 批量解密(避免重复 DEK 加载)
pub fn decrypt_batch(&self, dek: &[u8; 32], ciphertexts: &[String]) -> Result<Vec<String>>;
/// 获取/创建指定租户的 DEK
pub async fn get_dek(&self, tenant_id: Uuid, db: &DatabaseConnection) -> Result<DekWithVersion>;
/// 轮换指定租户的 DEK
pub async fn rotate_dek(&self, tenant_id: Uuid, db: &DatabaseConnection) -> Result<()>;
}
5.3 依赖关系变化
Before: erp-health → (内部 crypto.rs)
After: erp-core (crypto/) ← erp-health (调用 PiiCrypto)
← 未来其他模块
5.4 Cargo.toml 依赖变更
erp-core/Cargo.toml 需要新增:
[dependencies]
aes-gcm = "0.10"
hmac = "0.12"
sha2 = "0.10"
base64 = "0.22"
hex = "0.4"
dashmap = "6"
erp-health/Cargo.toml 可移除 aes-gcm, hmac, sha2, base64, hex(已提升到 erp-core),改为依赖 erp-core 的 crypto 模块。
5.5 erp-ai 依赖关系澄清
当前 erp-ai 通过 HealthDataProvider trait 获取已脱敏的 DTO(不含 name/phone/id_number),不需要直接访问加密数据。PiiCrypto 提升到 erp-core 后,erp-ai 不需要调用 PiiCrypto 解密。AI 脱敏层继续作为独立防线运作。
如果未来 AI 需要原始 PII 数据(如精确匹配患者),可通过新增 HealthDataProviderWithPii trait 提供,此时才需要 erp-ai 调用 PiiCrypto。
6. 每租户独立密钥管理
6.1 密钥层级
Master KEK (Key Encryption Key)
来源: 环境变量 ERP__CRYPTO__KEK
用途: 加密保护所有租户的 DEK
格式: 32 字节 hex-encoded
└── Per-Tenant DEK (Data Encryption Key)
来源: 首次使用时随机生成,用 KEK 加密后存入 DB
用途: 加密该租户的 PII 数据
格式: 32 字节随机
6.2 数据库表
CREATE TABLE tenant_crypto_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
encrypted_dek VARCHAR(128) NOT NULL, -- AES-256-GCM 加密的 DEK
key_version INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID,
updated_by UUID,
deleted_at TIMESTAMPTZ,
version INTEGER NOT NULL DEFAULT 1,
UNIQUE(tenant_id, key_version)
);
-- 项目规范要求所有表包含: id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version
6.3 DEK 缓存策略
| 参数 | 值 | 原因 |
|---|---|---|
| 最大缓存数 | 100 个租户 | 典型 SaaS 实例租户数 < 100 |
| TTL | 5 分钟 | 平衡安全性和性能 |
| 淘汰策略 | LRU | 简单有效 |
| 强制刷新 | 密钥轮换后 | 确保新密钥立即生效 |
使用 DashMap(并发安全 HashMap)实现缓存,避免每次请求查询数据库。
6.4 密钥轮换流程
- 管理员调用
POST /api/v1/admin/tenants/{id}/rotate-key - 生成新 DEK,用 KEK 加密,写入
tenant_crypto_keys表(version +1) - 后台任务启动:
- 读取该租户所有加密数据
- 用旧 DEK 解密 → 用新 DEK 加密
- 更新
key_version字段 - 分批处理(每批 100 条),每批提交一次
- 轮换期间,读取侧根据每条记录的
key_version字段精确选择对应 DEK,不做"尝试解密 + 回退"(避免掩盖数据篡改或密钥管理错误) - 全部完成后,标记旧 DEK
is_active = false
7. Entity 层改造
7.1 新增伴生字段
每个需要 HMAC 搜索的字段,在 SeaORM Entity 中新增 _hash 列:
| Entity | 新增字段 | 迁移操作 |
|---|---|---|
| patient | emergency_contact_phone_hash: Option<String> |
ALTER TABLE ADD COLUMN |
| patient_family_member | phone_hash: Option<String> |
ALTER TABLE ADD COLUMN |
| doctor_profile | license_number_hash: Option<String> |
ALTER TABLE ADD COLUMN |
HMAC hash 列索引: 每个 _hash 列创建普通 B-tree 索引(CREATE INDEX idx_{table}_{field}_hash ON {table}({field}_hash) WHERE {field}_hash IS NOT NULL),确保 HMAC 搜索不会退化为全表扫描。
7.2 Entity Model 变化示例
// crates/erp-health/src/entity/patient.rs
// 改造前:
pub struct Model {
pub id_number: Option<String>, // 已加密
pub id_number_hash: Option<String>, // 已有
pub emergency_contact_phone: Option<String>, // 明文
pub allergy_history: Option<String>, // 明文
// ...
}
// 改造后:
pub struct Model {
pub id_number: Option<String>, // 已加密 (不变)
pub id_number_hash: Option<String>, // 已有 (不变)
pub emergency_contact_phone: Option<String>, // 现在存储加密值
pub emergency_contact_phone_hash: Option<String>, // 新增: HMAC 搜索索引
pub allergy_history: Option<String>, // 现在存储加密值
// ...
}
重要: Entity 字段类型不变(仍为 Option<String>),只是存储内容从明文变为密文。这确保 SeaORM 查询层无需改动。
8. Service 层集成
8.1 加密/解密注入点
Service 层在每个 CRUD 操作中透明处理加密:
| 操作 | 加密处理 | 解密处理 |
|---|---|---|
| Create | 加密 Tier 1 字段 + 生成 HMAC | — |
| Update | 重新加密变更的 Tier 1 字段 + 更新 HMAC | — |
| Get by ID | — | 解密 Tier 1 字段 |
| List | — | 列表不展示加密字段(返回 None) |
| Search | HMAC hash 替代明文搜索条件 | — |
8.2 PiiCrypto 注入方式
// erp-server/src/state.rs
pub struct HealthState {
pub pii_crypto: PiiCrypto, // 替换原来的 HealthCrypto
// ...
}
// patient_service.rs
pub async fn create_patient(
db: &DatabaseConnection,
crypto: &PiiCrypto,
tenant_id: Uuid,
input: CreatePatientRequest,
) -> Result<PatientResponse> {
let dek = crypto.get_dek(tenant_id, db).await?;
// 加密 Tier 1 字段
let encrypted_phone = input.emergency_contact_phone
.map(|p| crypto.encrypt(&dek.key, &p))
.transpose()?;
let phone_hash = input.emergency_contact_phone
.map(|p| crypto.hmac_hash(&dek.key, &p));
// ... 插入数据库
}
9. 数据迁移
9.1 迁移策略
采用渐进式迁移,不锁表,不中断服务:
- 部署新代码 — 新代码根据
key_version字段判断:NULL= 明文(直接返回),有值 = 密文(解密后返回)。不依赖 Base64 格式检测(自由文本字段本身可能是合法 Base64)。 - 后台迁移任务 — 逐条加密现有明文数据
- 完成切换 — 所有数据加密后,移除明文兼容路径
9.2 迁移任务设计
pub async fn migrate_pii_encryption(
db: &DatabaseConnection,
crypto: &PiiCrypto,
tenant_id: Uuid,
) -> Result<MigrationReport> {
let dek = crypto.get_dek(tenant_id, db).await?;
let batch_size = 100;
loop {
// 读取一批未迁移的记录(key_version < current 或 key_version IS NULL)
let batch = find_unencrypted_patients(db, tenant_id, batch_size).await?;
if batch.is_empty() { break; }
for patient in &batch {
// 加密明文字段
let encrypted_phone = patient.emergency_contact_phone.as_ref()
.map(|p| crypto.encrypt(&dek.key, p))
.transpose()?;
let phone_hash = patient.emergency_contact_phone.as_ref()
.map(|p| crypto.hmac_hash(&dek.key, p));
// 更新记录
update_patient_encrypted(db, patient.id, encrypted_phone, phone_hash, dek.version).await?;
}
sleep(Duration::from_millis(50)).await; // 避免压垮数据库
}
}
9.3 Entity 新增 key_version 字段
所有包含 Tier 1 字段的 Entity 新增 key_version: Option<i32> 列,用于:
- 跟踪每条记录使用的 DEK 版本
- 支持渐进式迁移和轮换
- NULL 表示未加密(明文)
9.4 回滚计划
部署后如果需要回滚到旧代码:
- 新创建的加密数据: 旧代码无法读取(
key_version非空的记录)。回滚前需要运行"解密迁移"脚本,将加密数据还原为明文。 - 未迁移的明文数据: 旧代码可正常读取。
- 回滚脚本: 提供与加密迁移对称的解密迁移脚本,从最新
key_version的 DEK 解密所有字段,清除key_version(设为 NULL)。 - 回滚窗口: 建议在 Phase A 部署后 24 小时内保持回滚能力,超过 24 小时确认无问题后可移除明文兼容路径。
10. 边界场景处理
10.1 列表页性能
策略: 列表页不返回 Tier 1 加密字段(设为 None),仅展示 Tier 2/3 字段。
这与当前 model_to_resp 的设计一致(列表中 id_number 已经返回 None),无需改变交互模式。
前端影响评估: 当前 model_to_resp 已经将 emergency_contact_phone 做脱敏处理(mask_phone)后返回。加密后,列表页的 model_to_resp 需要将该字段设为 None(因为不解密),详情页的 model_to_resp_decrypted 需要解密后再脱敏。前端列表页如果展示了 emergency_contact_phone,需要适配为"详情中查看"。patient_family_member 和 doctor_profile 同理。
10.2 AI 分析管道
erp-ai 不直接调用 PiiCrypto。它通过 HealthDataProvider trait(在 erp-core 中)获取已脱敏的 DTO,不含 name/phone/id_number 等原始 PII。PiiCrypto 对 erp-ai 完全透明——erp-health 在提供数据给 erp-ai 之前已经在 Service 层完成了解密+脱敏。AI 脱敏层(SanitizationService)作为二次防护,确保发送给 LLM 的数据不包含原始 PII。
10.3 数据导出
导出操作需要独立的权限码(如 patient.export),Service 层解密后输出。审计日志记录所有导出操作。
10.4 小程序端
小程序不直接处理加密数据。所有加密/解密在服务端完成,API 响应中的 Tier 2 字段做脱敏处理。
10.5 跨租户数据泄漏
即使数据库层面发生泄漏(如 SQL 注入),攻击者没有 KEK 就无法解密 DEK,没有 DEK 就无法解密 PII 数据。HMAC hash 字段不可逆,不泄漏原始值。
11. 分步实施计划
Phase A — 基础设施 + Patient 实体(2-3 天)
目标: 加密基础设施落地,patient 实体全面加密。
| 步骤 | 内容 | 预估 |
|---|---|---|
| A1 | 提升加密基础设施到 erp-core/src/crypto/ | 0.5 天 |
| A2 | 实现 tenant_crypto_keys 表 + DEK 管理 |
0.5 天 |
| A3 | 修改 patient Entity(新增 hash 字段 + key_version) | 0.5 天 |
| A4 | 修改 patient_service 集成 PiiCrypto | 0.5 天 |
| A5 | 数据迁移脚本(patient 表现有数据) | 0.5 天 |
| A6 | 单元测试 + 集成测试 | 0.5 天 |
验证标准:
cargo check+cargo test全部通过- 创建新患者 → 数据库中 phone/allergy 为密文,hash 正确
- 查询患者详情 → API 返回解密后的明文
- 搜索电话号码 → 通过 HMAC hash 匹配
Phase B — 全 Entity 扩展 + 密钥轮换(2-3 天)
目标: 所有 Tier 1 字段加密,管理端支持密钥轮换。
| 步骤 | 内容 | 预估 |
|---|---|---|
| B1 | consultation_message, follow_up_record 加密 | 0.5 天 |
| B2 | patient_family_member, doctor_profile 加密 | 0.5 天 |
| B3 | dialysis_record, lab_report 加密 | 0.5 天 |
| B4 | 密钥轮换管理端点 + 后台任务 | 0.5 天 |
| B5 | DEK 缓存(DashMap LRU) | 0.5 天 |
| B6 | 全量集成测试 + 性能基准 | 0.5 天 |
验证标准:
- 所有 Tier 1 字段在数据库中为密文
- 所有现有 API 功能正常(透明加密/解密)
- 密钥轮换后数据可正常读写
- 批量解密 50 条记录 < 10ms
12. 测试策略
12.1 单元测试
| 测试类别 | 覆盖内容 | 数量 |
|---|---|---|
| 加密引擎 | encrypt/decrypt 正确性、随机 nonce 不可预测 | 5 |
| HMAC 索引 | 相同输入相同输出、不同输入不同输出 | 3 |
| 密钥管理 | DEK 生成/缓存/轮换 | 5 |
| 脱敏管道 | 各脱敏规则正确性 | 4 |
12.2 集成测试
| 测试类别 | 覆盖内容 | 数量 |
|---|---|---|
| CRUD 加密流 | 创建→加密存储→查询→解密返回 | 8 |
| HMAC 搜索 | 精确匹配搜索正确性 | 3 |
| 多租户隔离 | 租户 A 的 DEK 无法解密租户 B 的数据 | 2 |
| 密钥轮换 | 轮换前后数据可正常访问 | 2 |
12.3 性能测试
| 指标 | 基准 | 目标 |
|---|---|---|
| 单字段加密 | — | < 0.05ms |
| 单字段解密 | — | < 0.05ms |
| 50 条批量解密 | — | < 10ms |
| HMAC 生成 | — | < 0.01ms |
13. 风险与缓解
| 风险 | 概率 | 影响 | 缓解措施 |
|---|---|---|---|
| 加密后搜索性能下降 | 低 | 中 | HMAC 索引 + B-tree 索引 |
| 迁移过程中服务中断 | 低 | 高 | 渐进式迁移 + 兼容读取 |
| DEK 缓存一致性 | 中 | 中 | TTL + 轮换后强制刷新 |
| 密钥泄漏 | 低 | 极高 | KEK 不存数据库 + 环境变量注入 + 泄漏恢复流程(见下) |
| 加密字段误漏 | 中 | 中 | 代码审查 + 单元测试覆盖 |
| KEK 泄漏灾难恢复 | 极低 | 极高 | 见 §14 灾难恢复 |
14. KEK 泄漏灾难恢复
场景: Master KEK 泄露(如环境变量配置文件意外提交到 Git)。
恢复流程:
- 立即轮换 KEK — 生成新 KEK,更新环境变量
- 重新包装所有 DEK — 用新 KEK 解密并重新加密每个租户的 DEK
-- 不需要重新加密 PII 数据,只需重新加密 DEK UPDATE tenant_crypto_keys SET encrypted_dek = new_kek_encrypt(dek) WHERE is_active = true; - 清除 DEK 缓存 — 强制所有实例从数据库重新加载
- 轮换泄漏渠道 — 轮换所有可能暴露 KEK 的凭据(Git tokens、CI secrets、部署脚本)
- 审计追溯 — 检查访问日志,确认是否有未授权的数据访问
关键优势: KEK 泄漏只需要重新包装 DEK(N 次轻量操作),不需要重新加密所有 PII 数据(可能数百万条记录)。这是 KEK/DEK 两层架构的核心价值。
预防措施:
- KEK 通过 secrets manager 注入,不写入文件系统
- CI/CD 管道中扫描 KEK 值,防止意外提交
.gitignore排除.env文件
附录:决策记录
| 决策 | 选项 | 选择 | 原因 |
|---|---|---|---|
| 加密范围 | 全面/分级/TDE | 分级 | 成本与收益平衡 |
| 姓名是否加密 | 加密/不加密 | 不加密 | 医疗场景下姓名不属隐私,搜索性更重要 |
| 基础设施位置 | health/core/独立 crate | erp-core | 通用性 + 简洁性平衡 |
| 密钥策略 | 全局/per-tenant/渐进 | 每租户独立 | 安全隔离,泄漏影响面最小 |
| 实施节奏 | 一次完成/分步/最小可用 | 分步 (A+B) | 风险分散,每步可验证 |