- docs/: 设计规格、讨论记录、销售数据、健康管理文档 - scripts/: 辅助脚本 - package.json + pnpm-lock.yaml: monorepo 根配置
592 lines
22 KiB
Markdown
592 lines
22 KiB
Markdown
# PII 分级加密扩展设计
|
||
|
||
> 日期: 2026-04-26 | 状态: 草案 | 作者: iven + Claude
|
||
|
||
## 目录
|
||
|
||
1. [背景与动机](#1-背景与动机)
|
||
2. [设计目标](#2-设计目标)
|
||
3. [当前状态分析](#3-当前状态分析)
|
||
4. [分级加密策略](#4-分级加密策略)
|
||
5. [加密基础设施提升 (erp-core)](#5-加密基础设施提升)
|
||
6. [每租户独立密钥管理](#6-每租户独立密钥管理)
|
||
7. [Entity 层改造](#7-entity-层改造)
|
||
8. [Service 层集成](#8-service-层集成)
|
||
9. [数据迁移](#9-数据迁移)
|
||
10. [边界场景处理](#10-边界场景处理)
|
||
11. [分步实施计划](#11-分步实施计划)
|
||
12. [测试策略](#12-测试策略)
|
||
13. [风险与缓解](#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_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 接口设计
|
||
|
||
```rust
|
||
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` 需要新增:
|
||
```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 数据库表
|
||
|
||
```sql
|
||
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_keys` 表(version +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 变化示例
|
||
|
||
```rust
|
||
// 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 注入方式
|
||
|
||
```rust
|
||
// 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 迁移任务设计
|
||
|
||
```rust
|
||
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)。
|
||
|
||
**恢复流程:**
|
||
|
||
1. **立即轮换 KEK** — 生成新 KEK,更新环境变量
|
||
2. **重新包装所有 DEK** — 用新 KEK 解密并重新加密每个租户的 DEK
|
||
```sql
|
||
-- 不需要重新加密 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 泄漏只需要重新包装 DEK(N 次轻量操作),不需要重新加密所有 PII 数据(可能数百万条记录)。这是 KEK/DEK 两层架构的核心价值。
|
||
|
||
**预防措施:**
|
||
- KEK 通过 secrets manager 注入,不写入文件系统
|
||
- CI/CD 管道中扫描 KEK 值,防止意外提交
|
||
- `.gitignore` 排除 `.env` 文件
|
||
|
||
---
|
||
|
||
## 附录:决策记录
|
||
|
||
| 决策 | 选项 | 选择 | 原因 |
|
||
|------|------|------|------|
|
||
| 加密范围 | 全面/分级/TDE | 分级 | 成本与收益平衡 |
|
||
| 姓名是否加密 | 加密/不加密 | 不加密 | 医疗场景下姓名不属隐私,搜索性更重要 |
|
||
| 基础设施位置 | health/core/独立 crate | erp-core | 通用性 + 简洁性平衡 |
|
||
| 密钥策略 | 全局/per-tenant/渐进 | 每租户独立 | 安全隔离,泄漏影响面最小 |
|
||
| 实施节奏 | 一次完成/分步/最小可用 | 分步 (A+B) | 风险分散,每步可验证 |
|