Files
hms/docs/superpowers/specs/2026-04-26-pii-encryption-expansion-design.md
iven 1265935fa3 chore: 设计规格文档 + 销售数据 + 脚本工具 + 根目录 monorepo 配置
- docs/: 设计规格、讨论记录、销售数据、健康管理文档
- scripts/: 辅助脚本
- package.json + pnpm-lock.yaml: monorepo 根配置
2026-04-28 00:20:37 +08:00

22 KiB
Raw Blame History

PII 分级加密扩展设计

日期: 2026-04-26 | 状态: 草案 | 作者: iven + Claude

目录

  1. 背景与动机
  2. 设计目标
  3. 当前状态分析
  4. 分级加密策略
  5. 加密基础设施提升 (erp-core)
  6. 每租户独立密钥管理
  7. Entity 层改造
  8. Service 层集成
  9. 数据迁移
  10. 边界场景处理
  11. 分步实施计划
  12. 测试策略
  13. 风险与缓解

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 — 症状 JSON
  • complication_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_numberid_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, notespatient 和 health_record 的备注字段,内容不可预测且非临床核心)等。

分类澄清: notes 字段在不同 Entity 中分级不同。patient.notes 归入 Tier 3一般备注diagnosis.notes 归入 Tier 1临床诊断备注follow_up_recordresult/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 密钥轮换流程

  1. 管理员调用 POST /api/v1/admin/tenants/{id}/rotate-key
  2. 生成新 DEK用 KEK 加密,写入 tenant_crypto_keysversion +1
  3. 后台任务启动:
    • 读取该租户所有加密数据
    • 用旧 DEK 解密 → 用新 DEK 加密
    • 更新 key_version 字段
    • 分批处理(每批 100 条),每批提交一次
  4. 轮换期间,读取侧根据每条记录的 key_version 字段精确选择对应 DEK不做"尝试解密 + 回退"(避免掩盖数据篡改或密钥管理错误)
  5. 全部完成后,标记旧 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 迁移策略

采用渐进式迁移,不锁表,不中断服务:

  1. 部署新代码 — 新代码根据 key_version 字段判断:NULL = 明文(直接返回),有值 = 密文(解密后返回)。不依赖 Base64 格式检测(自由文本字段本身可能是合法 Base64
  2. 后台迁移任务 — 逐条加密现有明文数据
  3. 完成切换 — 所有数据加密后,移除明文兼容路径

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_memberdoctor_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.exportService 层解密后输出。审计日志记录所有导出操作。

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

恢复流程:

  1. 立即轮换 KEK — 生成新 KEK更新环境变量
  2. 重新包装所有 DEK — 用新 KEK 解密并重新加密每个租户的 DEK
    -- 不需要重新加密 PII 数据,只需重新加密 DEK
    UPDATE tenant_crypto_keys SET encrypted_dek = new_kek_encrypt(dek) WHERE is_active = true;
    
  3. 清除 DEK 缓存 — 强制所有实例从数据库重新加载
  4. 轮换泄漏渠道 — 轮换所有可能暴露 KEK 的凭据Git tokens、CI secrets、部署脚本
  5. 审计追溯 — 检查访问日志,确认是否有未授权的数据访问

关键优势: KEK 泄漏只需要重新包装 DEKN 次轻量操作),不需要重新加密所有 PII 数据(可能数百万条记录)。这是 KEK/DEK 两层架构的核心价值。

预防措施:

  • KEK 通过 secrets manager 注入,不写入文件系统
  • CI/CD 管道中扫描 KEK 值,防止意外提交
  • .gitignore 排除 .env 文件

附录:决策记录

决策 选项 选择 原因
加密范围 全面/分级/TDE 分级 成本与收益平衡
姓名是否加密 加密/不加密 不加密 医疗场景下姓名不属隐私,搜索性更重要
基础设施位置 health/core/独立 crate erp-core 通用性 + 简洁性平衡
密钥策略 全局/per-tenant/渐进 每租户独立 安全隔离,泄漏影响面最小
实施节奏 一次完成/分步/最小可用 分步 (A+B) 风险分散,每步可验证