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]