fix(health): PII 加密安全审计修复 — 2 Critical + 6 Medium + 4 Low
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

审计发现 55 检查点,46 PASS / 7 WARN / 2 FAIL,修复内容:

Critical:
- C1: 密钥轮换端点现在持久化新 DEK 到 tenant_crypto_keys 表
- C2: CachedDek 实现 Drop trait,释放时清零密钥材料

Medium:
- M1: 密文格式添加版本前缀 0x01,向后兼容旧格式
- M2: HMAC 索引使用独立子密钥,与加密 KEK 分离
- M4: 脱敏函数使用 chars() 迭代器,UTF-8 安全
- M5-M6: 医生执业证号详情响应脱敏 (mask_license_number)

Low:
- L1: dek_manager 改为 pub(crate),暴露 invalidate_dek() 方法
- L3: 合并 patient 列表搜索中冗余的重复 HMAC 计算
- L4: update_family_member/update_doctor 更新时设置 key_version
This commit is contained in:
iven
2026-04-26 13:34:25 +08:00
parent 3723cd93c0
commit 7ab57ea1b2
9 changed files with 324 additions and 36 deletions

View File

@@ -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<String, String> {
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<String, String> {
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())
}

View File

@@ -0,0 +1,24 @@
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// 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 = <Sha256 as Digest>::new()
.chain_update(b"pii-hmac-index-v1")
.chain_update(kek)
.finalize();
let mut key = [0u8; 32];
key.copy_from_slice(&derived);
key
}

View File

@@ -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 缓存管理 — 每租户独立 DEKLRU + TTL
#[derive(Clone)]
pub struct DekManager {

View File

@@ -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<char> = 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<String> {
s.map(|p| {
if p.len() >= 7 {
format!("{}****{}", &p[..3], &p[p.len() - 4..])
let chars: Vec<char> = 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<char> = 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(""));
}
}

View File

@@ -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 = <sha2::Sha256 as Digest>::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 = <sha2::Sha256 as Digest>::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]

View File

@@ -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,

View File

@@ -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);

View File

@@ -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<S>(
State(state): State<AppState>,
Extension(ctx): Extension<TenantContext>,
@@ -23,25 +24,53 @@ where
{
require_permission(&ctx, "tenant.manage")?;
// 读取当前最大版本号
let max_version: Option<i32> = {
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::<i32>(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 解密"
}))))
}