diff --git a/crates/erp-core/src/crypto/engine.rs b/crates/erp-core/src/crypto/engine.rs new file mode 100644 index 0000000..0d31837 --- /dev/null +++ b/crates/erp-core/src/crypto/engine.rs @@ -0,0 +1,46 @@ +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use rand::RngCore; + +const CIPHER_VERSION: u8 = 0x01; + +/// AES-256-GCM 加密。输出格式: Base64(0x01 || nonce[12] || ciphertext + tag) +pub fn encrypt(key: &[u8; 32], plaintext: &str) -> Result { + let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?; + let mut nonce_bytes = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let ciphertext = cipher + .encrypt(nonce, plaintext.as_bytes()) + .map_err(|e| e.to_string())?; + let mut combined = vec![CIPHER_VERSION]; + combined.extend_from_slice(&nonce_bytes); + combined.extend_from_slice(&ciphertext); + Ok(BASE64.encode(&combined)) +} + +/// AES-256-GCM 解密。支持 v1 格式: Base64(0x01 || nonce[12] || ciphertext + tag) +/// 兼容旧格式: Base64(nonce[12] || ciphertext + tag) +pub fn decrypt(key: &[u8; 32], encoded: &str) -> Result { + let bytes = BASE64.decode(encoded).map_err(|e| e.to_string())?; + if bytes.len() < 13 { + return Err("ciphertext too short".into()); + } + + let (nonce_bytes, ciphertext) = if bytes[0] == CIPHER_VERSION { + // v1: version(1) + nonce(12) + ciphertext + if bytes.len() < 14 { + return Err("v1 ciphertext too short".into()); + } + (&bytes[1..13], &bytes[13..]) + } else { + // 旧格式: nonce(12) + ciphertext(向后兼容) + (&bytes[0..12], &bytes[12..]) + }; + + let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?; + let nonce = Nonce::from_slice(nonce_bytes); + let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|e| e.to_string())?; + String::from_utf8(plaintext).map_err(|e| e.to_string()) +} diff --git a/crates/erp-core/src/crypto/hmac_index.rs b/crates/erp-core/src/crypto/hmac_index.rs new file mode 100644 index 0000000..ba11766 --- /dev/null +++ b/crates/erp-core/src/crypto/hmac_index.rs @@ -0,0 +1,24 @@ +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +/// HMAC-SHA256 搜索索引。使用 KEK 派生的独立子密钥,与加密密钥分离。 +pub fn hmac_hash(key: &[u8; 32], value: &str) -> String { + let hmac_key = derive_hmac_key(key); + let mut mac = HmacSha256::new_from_slice(&hmac_key).expect("HMAC key length is valid"); + mac.update(value.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +/// 从 KEK 派生独立的 HMAC 子密钥,避免密钥复用 +fn derive_hmac_key(kek: &[u8; 32]) -> [u8; 32] { + use sha2::Digest; + let derived = ::new() + .chain_update(b"pii-hmac-index-v1") + .chain_update(kek) + .finalize(); + let mut key = [0u8; 32]; + key.copy_from_slice(&derived); + key +} diff --git a/crates/erp-core/src/crypto/key_manager.rs b/crates/erp-core/src/crypto/key_manager.rs index dc8cb18..5d7002f 100644 --- a/crates/erp-core/src/crypto/key_manager.rs +++ b/crates/erp-core/src/crypto/key_manager.rs @@ -7,7 +7,7 @@ use crate::error::{AppError, AppResult}; use super::engine; -/// DEK 缓存条目 +/// DEK 缓存条目 — Drop 时清零密钥材料 #[derive(Clone)] struct CachedDek { dek: [u8; 32], @@ -15,6 +15,12 @@ struct CachedDek { loaded_at: Instant, } +impl Drop for CachedDek { + fn drop(&mut self) { + self.dek.fill(0); + } +} + /// DEK 缓存管理 — 每租户独立 DEK,LRU + TTL #[derive(Clone)] pub struct DekManager { diff --git a/crates/erp-core/src/crypto/masking.rs b/crates/erp-core/src/crypto/masking.rs index 360ea8d..8ab2f6e 100644 --- a/crates/erp-core/src/crypto/masking.rs +++ b/crates/erp-core/src/crypto/masking.rs @@ -1,7 +1,10 @@ /// 身份证号脱敏: 保留前 3 位和后 4 位,中间用 **** 替代 pub fn mask_id_number(s: &str) -> String { - if s.len() >= 7 { - format!("{}****{}", &s[..3], &s[s.len() - 4..]) + let chars: Vec = s.chars().collect(); + if chars.len() >= 7 { + let head: String = chars[..3].iter().collect(); + let tail: String = chars[chars.len() - 4..].iter().collect(); + format!("{}****{}", head, tail) } else { "****".to_string() } @@ -10,14 +13,29 @@ pub fn mask_id_number(s: &str) -> String { /// 手机号脱敏: 保留前 3 位和后 4 位,中间用 **** 替代 pub fn mask_phone(s: Option<&str>) -> Option { s.map(|p| { - if p.len() >= 7 { - format!("{}****{}", &p[..3], &p[p.len() - 4..]) + let chars: Vec = p.chars().collect(); + if chars.len() >= 7 { + let head: String = chars[..3].iter().collect(); + let tail: String = chars[chars.len() - 4..].iter().collect(); + format!("{}****{}", head, tail) } else { "****".to_string() } }) } +/// 执业证号脱敏: 保留前 2 位和后 2 位,中间用 **** 替代 +pub fn mask_license_number(s: &str) -> String { + let chars: Vec = s.chars().collect(); + if chars.len() >= 5 { + let head: String = chars[..2].iter().collect(); + let tail: String = chars[chars.len() - 2..].iter().collect(); + format!("{}****{}", head, tail) + } else { + "****".to_string() + } +} + #[cfg(test)] mod tests { use super::*; @@ -61,4 +79,29 @@ mod tests { fn mask_id_exactly_7() { assert_eq!("123****4567", mask_id_number("1234567")); } + + #[test] + fn mask_id_unicode_safe() { + assert_eq!("你好世****cdef", mask_id_number("你好世界abcdef")); + } + + #[test] + fn mask_phone_unicode_safe() { + assert_eq!(Some("你好世****cdef".to_string()), mask_phone(Some("你好世界abcdef"))); + } + + #[test] + fn mask_license_normal() { + assert_eq!("YL****23", mask_license_number("YL-2024-00123")); + } + + #[test] + fn mask_license_short() { + assert_eq!("****", mask_license_number("AB")); + } + + #[test] + fn mask_license_empty() { + assert_eq!("****", mask_license_number("")); + } } diff --git a/crates/erp-core/src/crypto/mod.rs b/crates/erp-core/src/crypto/mod.rs index b56d0df..e56eba9 100644 --- a/crates/erp-core/src/crypto/mod.rs +++ b/crates/erp-core/src/crypto/mod.rs @@ -5,7 +5,7 @@ pub mod masking; pub use engine::{decrypt, encrypt}; pub use hmac_index::hmac_hash; -pub use masking::{mask_id_number, mask_phone}; +pub use masking::{mask_id_number, mask_license_number, mask_phone}; pub use key_manager::DekManager; use crate::error::{AppError, AppResult}; @@ -14,7 +14,8 @@ use crate::error::{AppError, AppResult}; #[derive(Clone)] pub struct PiiCrypto { kek: [u8; 32], - pub dek_manager: DekManager, + hmac_key: [u8; 32], + pub(crate) dek_manager: DekManager, } impl PiiCrypto { @@ -27,10 +28,7 @@ impl PiiCrypto { } let mut kek = [0u8; 32]; kek.copy_from_slice(&bytes); - Ok(Self { - kek, - dek_manager: DekManager::new(300, 100), // 5 min TTL, 100 entries - }) + Ok(Self::from_kek(kek)) } /// Dev fallback: 从固定字符串派生确定性 KEK。仅用于开发。 @@ -39,8 +37,20 @@ impl PiiCrypto { let kek = ::digest(b"erp-pii-kek-dev-key-DO-NOT-USE-IN-PROD"); let mut key = [0u8; 32]; key.copy_from_slice(&kek); + Self::from_kek(key) + } + + fn from_kek(kek: [u8; 32]) -> Self { + use sha2::Digest; + let hmac_key = ::new() + .chain_update(b"pii-hmac-index-v1") + .chain_update(&kek) + .finalize(); + let mut hk = [0u8; 32]; + hk.copy_from_slice(&hmac_key); Self { - kek: key, + kek, + hmac_key: hk, dek_manager: DekManager::new(300, 100), } } @@ -48,6 +58,16 @@ impl PiiCrypto { pub fn kek(&self) -> &[u8; 32] { &self.kek } + + /// HMAC 搜索索引使用的独立子密钥 + pub fn hmac_key(&self) -> &[u8; 32] { + &self.hmac_key + } + + /// 使指定租户的 DEK 缓存失效 + pub fn invalidate_dek(&self, tenant_id: uuid::Uuid) { + self.dek_manager.invalidate(tenant_id); + } } #[cfg(test)] @@ -104,19 +124,25 @@ mod tests { #[test] fn hmac_hash_deterministic() { let crypto = test_crypto(); - let h1 = hmac_hash(crypto.kek(), "13812345678"); - let h2 = hmac_hash(crypto.kek(), "13812345678"); + let h1 = hmac_hash(crypto.hmac_key(), "13812345678"); + let h2 = hmac_hash(crypto.hmac_key(), "13812345678"); assert_eq!(h1, h2); } #[test] fn hmac_hash_different_inputs() { let crypto = test_crypto(); - let h1 = hmac_hash(crypto.kek(), "111"); - let h2 = hmac_hash(crypto.kek(), "222"); + let h1 = hmac_hash(crypto.hmac_key(), "111"); + let h2 = hmac_hash(crypto.hmac_key(), "222"); assert_ne!(h1, h2); } + #[test] + fn hmac_key_differs_from_kek() { + let crypto = test_crypto(); + assert_ne!(crypto.kek(), crypto.hmac_key(), "HMAC 密钥应与 KEK 不同"); + } + #[test] fn encrypt_empty_string() { let crypto = test_crypto(); @@ -141,6 +167,15 @@ mod tests { assert_eq!(plaintext, decrypted); } + #[test] + fn ciphertext_has_version_prefix() { + let crypto = test_crypto(); + let encrypted = encrypt(crypto.kek(), "test").unwrap(); + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD.decode(&encrypted).unwrap(); + assert_eq!(bytes[0], 0x01, "密文首字节应为版本号 0x01"); + } + // ── 性能基准 ── #[test] diff --git a/crates/erp-health/src/service/doctor_service.rs b/crates/erp-health/src/service/doctor_service.rs index 80fdca5..0309779 100644 --- a/crates/erp-health/src/service/doctor_service.rs +++ b/crates/erp-health/src/service/doctor_service.rs @@ -34,7 +34,7 @@ pub async fn list_doctors( .filter(doctor_profile::Column::DeletedAt.is_null()); if let Some(ref s) = search { - let license_hash = pii::hmac_hash(state.crypto.kek(), s); + let license_hash = pii::hmac_hash(state.crypto.hmac_key(), s); query = query.filter( Condition::any() .add(doctor_profile::Column::Name.contains(s)) @@ -82,7 +82,7 @@ pub async fn create_doctor( let (encrypted_license, license_hash) = match req.license_number { Some(ref l) if !l.is_empty() => { let encrypted = pii::encrypt(state.crypto.kek(), l)?; - let hash = pii::hmac_hash(state.crypto.kek(), l); + let hash = pii::hmac_hash(state.crypto.hmac_key(), l); (Some(encrypted), Some(hash)) } _ => (None, None), @@ -149,9 +149,10 @@ pub async fn update_doctor( if let Some(v) = req.specialty { active.specialty = Set(Some(v)); } if let Some(v) = req.license_number { let encrypted = pii::encrypt(state.crypto.kek(), &v)?; - let hash = pii::hmac_hash(state.crypto.kek(), &v); + let hash = pii::hmac_hash(state.crypto.hmac_key(), &v); active.license_number = Set(Some(encrypted)); active.license_number_hash = Set(Some(hash)); + active.key_version = Set(Some(1)); } if let Some(v) = req.bio { active.bio = Set(Some(v)); } if let Some(ref v) = req.online_status { @@ -241,7 +242,8 @@ fn model_to_resp(m: doctor_profile::Model) -> DoctorResp { fn model_to_resp_decrypted(crypto: &erp_core::crypto::PiiCrypto, m: doctor_profile::Model) -> DoctorResp { let license = m.license_number.as_ref() - .map(|l| pii::decrypt(crypto.kek(), l).unwrap_or_else(|_| l.clone())); + .map(|l| pii::decrypt(crypto.kek(), l).unwrap_or_else(|_| l.clone())) + .map(|l| pii::mask_license_number(&l)); DoctorResp { id: m.id, user_id: m.user_id, diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs index 0bfcd7b..c1e3085 100644 --- a/crates/erp-health/src/service/patient_service.rs +++ b/crates/erp-health/src/service/patient_service.rs @@ -58,13 +58,12 @@ pub async fn list_patients( .filter(patient::Column::DeletedAt.is_null()); if let Some(ref search) = search { - let search_hash = pii::hmac_hash(state.crypto.kek(), search); - let phone_hash = pii::hmac_hash(state.crypto.kek(), search); + let search_hash = pii::hmac_hash(state.crypto.hmac_key(), search); query = query.filter( Condition::any() .add(patient::Column::Name.contains(search)) - .add(patient::Column::IdNumberHash.eq(search_hash)) - .add(patient::Column::EmergencyContactPhoneHash.eq(phone_hash)), + .add(patient::Column::IdNumberHash.eq(&search_hash)) + .add(patient::Column::EmergencyContactPhoneHash.eq(search_hash)), ); } @@ -113,7 +112,7 @@ pub async fn create_patient( let (encrypted_id_number, id_number_hash) = match req.id_number { Some(ref plain) if !plain.is_empty() => { let encrypted = pii::encrypt(state.crypto.kek(), plain)?; - let hash = pii::hmac_hash(state.crypto.kek(), plain); + let hash = pii::hmac_hash(state.crypto.hmac_key(), plain); (Some(encrypted), Some(hash)) } _ => (None, None), @@ -123,7 +122,7 @@ pub async fn create_patient( let (encrypted_phone, phone_hash) = match req.emergency_contact_phone { Some(ref p) if !p.is_empty() => { let encrypted = pii::encrypt(state.crypto.kek(), p)?; - let hash = pii::hmac_hash(state.crypto.kek(), p); + let hash = pii::hmac_hash(state.crypto.hmac_key(), p); (Some(encrypted), Some(hash)) } _ => (None, None), @@ -248,7 +247,7 @@ pub async fn update_patient( if let Some(v) = req.blood_type { active.blood_type = Set(Some(v)); } if let Some(ref plain) = req.id_number { let encrypted = pii::encrypt(state.crypto.kek(), plain)?; - let hash = pii::hmac_hash(state.crypto.kek(), plain); + let hash = pii::hmac_hash(state.crypto.hmac_key(), plain); active.id_number = Set(Some(encrypted)); active.id_number_hash = Set(Some(hash)); } @@ -263,7 +262,7 @@ pub async fn update_patient( if let Some(v) = req.emergency_contact_name { active.emergency_contact_name = Set(Some(v)); } if let Some(ref v) = req.emergency_contact_phone { let encrypted = pii::encrypt(state.crypto.kek(), v)?; - let hash = pii::hmac_hash(state.crypto.kek(), v); + let hash = pii::hmac_hash(state.crypto.hmac_key(), v); active.emergency_contact_phone = Set(Some(encrypted)); active.emergency_contact_phone_hash = Set(Some(hash)); } @@ -603,14 +602,16 @@ pub async fn update_family_member( .map_err(|_| HealthError::VersionMismatch)?; let kek = state.crypto.kek(); + let hmac_key = state.crypto.hmac_key(); let mut active: patient_family_member::ActiveModel = model.into(); active.name = Set(req.name); active.relationship = Set(req.relationship); if let Some(ref p) = req.phone { let encrypted = pii::encrypt(kek, p)?; - let hash = pii::hmac_hash(kek, p); + let hash = pii::hmac_hash(hmac_key, p); active.phone = Set(Some(encrypted)); active.phone_hash = Set(Some(hash)); + active.key_version = Set(Some(1)); } active.birth_date = Set(req.birth_date); active.notes = Set(req.notes); diff --git a/crates/erp-server/src/handlers/crypto_admin.rs b/crates/erp-server/src/handlers/crypto_admin.rs index 699dece..384f0c1 100644 --- a/crates/erp-server/src/handlers/crypto_admin.rs +++ b/crates/erp-server/src/handlers/crypto_admin.rs @@ -1,6 +1,7 @@ use axum::extract::{FromRef, Path, State}; use axum::Extension; use axum::Json; +use sea_orm::{ConnectionTrait, Statement, DatabaseBackend}; use serde_json::{json, Value}; use uuid::Uuid; @@ -11,7 +12,7 @@ use erp_core::types::{ApiResponse, TenantContext}; use crate::state::AppState; /// POST /api/v1/admin/tenants/:id/rotate-key -/// 密钥轮换 — 生成新 DEK 并使缓存失效 +/// 密钥轮换 — 生成新 DEK,持久化到 tenant_crypto_keys,使缓存失效 pub async fn rotate_tenant_key( State(state): State, Extension(ctx): Extension, @@ -23,25 +24,53 @@ where { require_permission(&ctx, "tenant.manage")?; + // 读取当前最大版本号 + let max_version: Option = { + let row = state.db.query_one(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT COALESCE(MAX(key_version), 0) as v FROM tenant_crypto_keys WHERE tenant_id = $1 AND deleted_at IS NULL", + [tenant_id.into()], + )).await.map_err(|e| AppError::Internal(format!("查询密钥版本失败: {}", e)))?; + row.and_then(|r| r.try_get_by_index::(0).ok()) + }; + let current_version = max_version.unwrap_or(0); + let new_version = current_version + 1; + + // 将旧版本标记为不活跃 + state.db.execute(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE tenant_crypto_keys SET is_active = false, updated_at = now() WHERE tenant_id = $1 AND is_active = true AND deleted_at IS NULL", + [tenant_id.into()], + )).await.map_err(|e| AppError::Internal(format!("停用旧 DEK 失败: {}", e)))?; + + // 生成新 DEK 并用 KEK 加密 let kek = state.pii_crypto.kek(); - let (_new_dek, _encrypted_dek) = erp_core::crypto::DekManager::generate_new_dek(kek) + let (_new_dek, encrypted_dek) = erp_core::crypto::DekManager::generate_new_dek(kek) .map_err(|e| AppError::Internal(format!("生成新 DEK 失败: {}", e)))?; - let new_version = 1i32; // TODO: 从 tenant_crypto_keys 表读取当前版本 +1 + // 持久化新 DEK + let new_id = Uuid::now_v7(); + state.db.execute(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO tenant_crypto_keys (id, tenant_id, encrypted_dek, key_version, is_active, created_at, updated_at, version) VALUES ($1, $2, $3, $4, true, now(), now(), 1)", + [new_id.into(), tenant_id.into(), encrypted_dek.into(), new_version.into()], + )).await.map_err(|e| AppError::Internal(format!("存储新 DEK 失败: {}", e)))?; // 使 DEK 缓存失效 - state.pii_crypto.dek_manager.invalidate(tenant_id); + state.pii_crypto.invalidate_dek(tenant_id); tracing::info!( tenant_id = %tenant_id, + old_version = current_version, new_version = new_version, - "密钥轮换已执行(DEK 缓存已清除)" + "密钥轮换完成(新 DEK 已持久化,缓存已清除)" ); Ok(Json(ApiResponse::ok(json!({ - "message": "密钥轮换已启动", + "message": "密钥轮换已完成", "tenant_id": tenant_id, + "old_version": current_version, "new_version": new_version, - "note": "后台重加密任务需要单独触发(当前为单 KEK 模式,轮换后新数据使用新 DEK)" + "note": "后台重加密任务需要单独触发,旧数据仍可用旧 DEK 解密" })))) } diff --git a/docs/discussions/2026-04-26-pii-encryption-audit.md b/docs/discussions/2026-04-26-pii-encryption-audit.md new file mode 100644 index 0000000..30b3fd6 --- /dev/null +++ b/docs/discussions/2026-04-26-pii-encryption-audit.md @@ -0,0 +1,102 @@ +# 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::()` + +### 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 字段 ✅ +- 详情接口解密+脱敏 ✅ +- 审计日志存密文(安全最佳实践)✅ +- 错误响应无敏感信息泄露 ✅