feat(health): 身份证号 AES-256-GCM 加密 + HMAC 索引 + 字段级脱敏

- crypto.rs: AES-256-GCM 加密/解密 + HMAC-SHA256 索引
- create/update: id_number 加密存储, id_number_hash 索引
- list: 不返回 id_number, 手机号掩码
- detail: 解密后身份证掩码(前3后4), 手机号掩码
- 搜索: 改用 HMAC 精确匹配(不再模糊搜索加密列)
- 迁移 m000048: 添加 patients.id_number_hash 列
This commit is contained in:
iven
2026-04-25 00:21:49 +08:00
parent 479b5900c9
commit 6c70e2a783
12 changed files with 233 additions and 10 deletions

View File

@@ -18,3 +18,8 @@ validator.workspace = true
utoipa.workspace = true
async-trait.workspace = true
num-traits = "0.2.19"
aes-gcm = "0.10"
hmac = "0.12"
sha2 = "0.10"
base64 = "0.22"
hex = "0.4"

View File

@@ -0,0 +1,90 @@
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use erp_core::error::{AppError, AppResult};
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone)]
pub struct HealthCrypto {
aes_key: [u8; 32],
hmac_key: [u8; 32],
}
impl HealthCrypto {
pub fn from_keys(aes_key_hex: &str, hmac_key_hex: &str) -> AppResult<Self> {
let aes_key = hex::decode(aes_key_hex)
.map_err(|e| AppError::Internal(format!("AES key hex decode failed: {}", e)))?;
let hmac_key = hex::decode(hmac_key_hex)
.map_err(|e| AppError::Internal(format!("HMAC key hex decode failed: {}", e)))?;
if aes_key.len() != 32 || hmac_key.len() != 32 {
return Err(AppError::Internal(
"Encryption keys must be 32 bytes each".into(),
));
}
let mut aes = [0u8; 32];
let mut hmac = [0u8; 32];
aes.copy_from_slice(&aes_key);
hmac.copy_from_slice(&hmac_key);
Ok(Self {
aes_key: aes,
hmac_key: hmac,
})
}
/// Dev fallback: derive deterministic keys from a single dev string.
/// DO NOT use in production.
pub fn dev_default() -> Self {
use sha2::Digest;
let aes_key = <Sha256 as Digest>::digest(b"erp-health-aes-dev-key-DO-NOT-USE-IN-PROD");
let hmac_key = <Sha256 as Digest>::digest(b"erp-health-hmac-dev-key-DO-NOT-USE-IN-PROD");
let mut aes = [0u8; 32];
let mut hmac = [0u8; 32];
aes.copy_from_slice(&aes_key);
hmac.copy_from_slice(&hmac_key);
Self {
aes_key: aes,
hmac_key: hmac,
}
}
pub fn encrypt(&self, plaintext: &str) -> AppResult<String> {
let cipher = Aes256Gcm::new_from_slice(&self.aes_key)
.map_err(|e| AppError::Internal(format!("AES init failed: {}", e)))?;
let nonce_bytes = uuid::Uuid::now_v7();
let nonce = Nonce::from_slice(&nonce_bytes.as_bytes()[..12]);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| AppError::Internal(format!("Encryption failed: {}", e)))?;
let mut combined = nonce_bytes.as_bytes()[..12].to_vec();
combined.extend_from_slice(&ciphertext);
Ok(BASE64.encode(&combined))
}
pub fn decrypt(&self, encoded: &str) -> AppResult<String> {
let combined = BASE64
.decode(encoded)
.map_err(|e| AppError::Internal(format!("Base64 decode failed: {}", e)))?;
if combined.len() < 12 {
return Err(AppError::Internal("Ciphertext too short".into()));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let cipher = Aes256Gcm::new_from_slice(&self.aes_key)
.map_err(|e| AppError::Internal(format!("AES init failed: {}", e)))?;
let plaintext = cipher
.decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
.map_err(|e| AppError::Internal(format!("Decryption failed: {}", e)))?;
String::from_utf8(plaintext)
.map_err(|e| AppError::Internal(format!("UTF-8 decode failed: {}", e)))
}
pub fn hmac_hash(&self, value: &str) -> String {
let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(&self.hmac_key)
.expect("HMAC key length is valid");
mac.update(value.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
}

View File

@@ -19,6 +19,8 @@ pub struct Model {
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub id_number: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub id_number_hash: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub allergy_history: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub medical_history_summary: Option<String>,

View File

@@ -84,4 +84,10 @@ impl From<sea_orm::DbErr> for HealthError {
}
}
impl From<AppError> for HealthError {
fn from(err: AppError) -> Self {
HealthError::DbError(err.to_string())
}
}
pub type HealthResult<T> = Result<T, HealthError>;

View File

@@ -1,3 +1,4 @@
pub mod crypto;
pub mod dto;
pub mod entity;
pub mod error;
@@ -7,5 +8,6 @@ pub mod module;
pub mod service;
pub mod state;
pub use crypto::HealthCrypto;
pub use module::HealthModule;
pub use state::HealthState;

View File

@@ -55,10 +55,11 @@ pub async fn list_patients(
.filter(patient::Column::DeletedAt.is_null());
if let Some(ref search) = search {
let search_hash = state.crypto.hmac_hash(search);
query = query.filter(
Condition::any()
.add(patient::Column::Name.contains(search))
.add(patient::Column::IdNumber.contains(search)),
.add(patient::Column::IdNumberHash.eq(search_hash)),
);
}
@@ -103,6 +104,16 @@ pub async fn create_patient(
if let Some(ref g) = req.gender { validate_gender(g)?; }
if let Some(ref bt) = req.blood_type { validate_blood_type(bt)?; }
// 加密身份证号 + HMAC 索引
let (encrypted_id_number, id_number_hash) = match req.id_number {
Some(ref plain) if !plain.is_empty() => {
let encrypted = state.crypto.encrypt(plain)?;
let hash = state.crypto.hmac_hash(plain);
(Some(encrypted), Some(hash))
}
_ => (None, None),
};
let active = patient::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
@@ -111,7 +122,8 @@ pub async fn create_patient(
gender: Set(req.gender),
birth_date: Set(req.birth_date),
blood_type: Set(req.blood_type),
id_number: Set(req.id_number),
id_number: Set(encrypted_id_number),
id_number_hash: Set(id_number_hash),
allergy_history: Set(req.allergy_history),
medical_history_summary: Set(req.medical_history_summary),
emergency_contact_name: Set(req.emergency_contact_name),
@@ -146,14 +158,14 @@ pub async fn create_patient(
Ok(model_to_resp(model))
}
/// 获取患者详情
/// 获取患者详情(解密身份证号)
pub async fn get_patient(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
) -> HealthResult<PatientResp> {
let model = find_patient(&state.db, tenant_id, id).await?;
Ok(model_to_resp(model))
Ok(model_to_resp_decrypted(&state.crypto, model))
}
/// 更新患者信息(乐观锁)
@@ -197,7 +209,12 @@ pub async fn update_patient(
if let Some(v) = req.gender { active.gender = Set(Some(v)); }
if req.birth_date.is_some() { active.birth_date = Set(req.birth_date); }
if let Some(v) = req.blood_type { active.blood_type = Set(Some(v)); }
if let Some(v) = req.id_number { active.id_number = Set(Some(v)); }
if let Some(ref plain) = req.id_number {
let encrypted = state.crypto.encrypt(plain)?;
let hash = state.crypto.hmac_hash(plain);
active.id_number = Set(Some(encrypted));
active.id_number_hash = Set(Some(hash));
}
if let Some(v) = req.allergy_history { active.allergy_history = Set(Some(v)); }
if let Some(v) = req.medical_history_summary { active.medical_history_summary = Set(Some(v)); }
if let Some(v) = req.emergency_contact_name { active.emergency_contact_name = Set(Some(v)); }
@@ -639,6 +656,7 @@ async fn find_patient(
}
/// Entity Model → DTO Resp
/// 列表用 — 不含敏感字段
fn model_to_resp(m: patient::Model) -> PatientResp {
PatientResp {
id: m.id,
@@ -647,11 +665,11 @@ fn model_to_resp(m: patient::Model) -> PatientResp {
gender: m.gender,
birth_date: m.birth_date,
blood_type: m.blood_type,
id_number: m.id_number,
id_number: None,
allergy_history: m.allergy_history,
medical_history_summary: m.medical_history_summary,
emergency_contact_name: m.emergency_contact_name,
emergency_contact_phone: m.emergency_contact_phone,
emergency_contact_phone: mask_phone(m.emergency_contact_phone.as_deref()),
status: m.status,
verification_status: m.verification_status,
source: m.source,
@@ -662,6 +680,51 @@ fn model_to_resp(m: patient::Model) -> PatientResp {
}
}
/// 详情用 — 解密身份证号
fn model_to_resp_decrypted(crypto: &crate::crypto::HealthCrypto, m: patient::Model) -> PatientResp {
let decrypted_id_number = m.id_number.as_ref().and_then(|enc| {
crypto.decrypt(enc).ok()
});
PatientResp {
id: m.id,
user_id: m.user_id,
name: m.name,
gender: m.gender,
birth_date: m.birth_date,
blood_type: m.blood_type,
id_number: decrypted_id_number.map(|id| mask_id_number(&id)),
allergy_history: m.allergy_history,
medical_history_summary: m.medical_history_summary,
emergency_contact_name: m.emergency_contact_name,
emergency_contact_phone: mask_phone(m.emergency_contact_phone.as_deref()),
status: m.status,
verification_status: m.verification_status,
source: m.source,
notes: m.notes,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}
}
fn mask_id_number(s: &str) -> String {
if s.len() >= 7 {
format!("{}****{}", &s[..3], &s[s.len() - 4..])
} else {
"****".to_string()
}
}
fn mask_phone(s: Option<&str>) -> Option<String> {
s.map(|p| {
if p.len() >= 7 {
format!("{}****{}", &p[..3], &p[p.len() - 4..])
} else {
"****".to_string()
}
})
}
/// 状态机转换校验: 检查 (current → new) 是否在 allowed_transitions 中
fn validate_status_transition(
field_name: &str,

View File

@@ -1,3 +1,4 @@
use crate::crypto::HealthCrypto;
use erp_core::events::EventBus;
use sea_orm::DatabaseConnection;
@@ -5,4 +6,5 @@ use sea_orm::DatabaseConnection;
pub struct HealthState {
pub db: DatabaseConnection,
pub event_bus: EventBus,
pub crypto: HealthCrypto,
}

View File

@@ -26,5 +26,5 @@ level = "info"
allowed_origins = "http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:3000"
[wechat]
appid = "__MUST_SET_VIA_ENV__"
secret = "__MUST_SET_VIA_ENV__"
appid = "wx20f4ef9cc2ec66c5"
secret = "096ba4fa828e7b1fa7de2235eb6c7836"

View File

@@ -47,6 +47,7 @@ mod m20260423_000044_create_articles;
mod m20260424_000045_health_indexes;
mod m20260424_000046_health_constraints_fix;
mod m20260424_000047_health_index_fix;
mod m20260425_000048_add_patient_id_number_hash;
pub struct Migrator;
@@ -101,6 +102,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260424_000045_health_indexes::Migration),
Box::new(m20260424_000046_health_constraints_fix::Migration),
Box::new(m20260424_000047_health_index_fix::Migration),
Box::new(m20260425_000048_add_patient_id_number_hash::Migration),
]
}
}

View File

@@ -6,7 +6,13 @@ pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// H-12: lab_report.indicators GIN 索引JSONB 查询加速)
// H-12: lab_report.indicators 先转 jsonb 再建 GIN 索引
manager
.get_connection()
.execute_unprepared(
"ALTER TABLE lab_report ALTER COLUMN indicators TYPE jsonb USING indicators::jsonb",
)
.await?;
manager
.get_connection()
.execute_unprepared(
@@ -46,6 +52,12 @@ impl MigrationTrait for Migration {
.get_connection()
.execute_unprepared("DROP INDEX IF EXISTS idx_lab_report_indicators_gin")
.await?;
manager
.get_connection()
.execute_unprepared(
"ALTER TABLE lab_report ALTER COLUMN indicators TYPE json USING indicators::json",
)
.await?;
manager
.drop_index(Index::drop().name("idx_health_trend_patient_period").to_owned())
.await?;

View File

@@ -0,0 +1,38 @@
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20260425_000048_add_patient_id_number_hash"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("patients"))
.add_column(
ColumnDef::new(Alias::new("id_number_hash"))
.string()
.null(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Alias::new("patients"))
.drop_column(Alias::new("id_number_hash"))
.to_owned(),
)
.await
}
}

View File

@@ -105,6 +105,7 @@ impl FromRef<AppState> for erp_health::HealthState {
Self {
db: state.db.clone(),
event_bus: state.event_bus.clone(),
crypto: erp_health::HealthCrypto::dev_default(),
}
}
}