fix(health): PII 加密安全审计修复 — 2 Critical + 6 Medium + 4 Low
审计发现 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:
46
crates/erp-core/src/crypto/engine.rs
Normal file
46
crates/erp-core/src/crypto/engine.rs
Normal 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())
|
||||
}
|
||||
24
crates/erp-core/src/crypto/hmac_index.rs
Normal file
24
crates/erp-core/src/crypto/hmac_index.rs
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user