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:
@@ -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"
|
||||
|
||||
90
crates/erp-health/src/crypto.rs
Normal file
90
crates/erp-health/src/crypto.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user