From 49b8300fdcc5be346b495be3938b30e712cddd41 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 26 Apr 2026 12:40:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20DEK=20=E7=BC=93=E5=AD=98=20+=20?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E8=BD=AE=E6=8D=A2=E7=AE=A1=E7=90=86=E7=AB=AF?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - erp-core/crypto/key_manager: DashMap LRU DEK 缓存 (TTL 5min, 100条) - DekManager: get_or_create_dek, generate_new_dek, invalidate - PiiCrypto 集成 DekManager - POST /api/v1/admin/tenants/:id/rotate-key: 生成新 DEK + 缓存失效 - 权限: tenant.manage (仅超级管理员) --- crates/erp-core/Cargo.toml | 7 + crates/erp-core/src/crypto/key_manager.rs | 120 +++++++++++++++ crates/erp-core/src/crypto/mod.rs | 143 ++++++++++++++++++ .../erp-server/src/handlers/crypto_admin.rs | 47 ++++++ crates/erp-server/src/handlers/mod.rs | 1 + crates/erp-server/src/main.rs | 4 + 6 files changed, 322 insertions(+) create mode 100644 crates/erp-core/src/crypto/key_manager.rs create mode 100644 crates/erp-core/src/crypto/mod.rs create mode 100644 crates/erp-server/src/handlers/crypto_admin.rs diff --git a/crates/erp-core/Cargo.toml b/crates/erp-core/Cargo.toml index 7d3b6df..26b9b8d 100644 --- a/crates/erp-core/Cargo.toml +++ b/crates/erp-core/Cargo.toml @@ -16,3 +16,10 @@ axum.workspace = true sea-orm.workspace = true async-trait.workspace = true utoipa.workspace = true +aes-gcm = "0.10" +hmac = "0.12" +sha2 = "0.10" +base64 = "0.22" +hex = "0.4" +rand = "0.8" +dashmap = "6" diff --git a/crates/erp-core/src/crypto/key_manager.rs b/crates/erp-core/src/crypto/key_manager.rs new file mode 100644 index 0000000..45ecf40 --- /dev/null +++ b/crates/erp-core/src/crypto/key_manager.rs @@ -0,0 +1,120 @@ +use std::sync::Arc; +use std::time::Instant; + +use dashmap::DashMap; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; + +use super::engine; + +/// DEK 缓存条目 +#[derive(Clone)] +struct CachedDek { + dek: [u8; 32], + version: u32, + loaded_at: Instant, +} + +/// DEK 缓存管理 — 每租户独立 DEK,LRU + TTL +#[derive(Clone)] +pub struct DekManager { + cache: DashMap, + ttl_secs: u64, + max_entries: usize, +} + +impl DekManager { + pub fn new(ttl_secs: u64, max_entries: usize) -> Self { + Self { + cache: DashMap::new(), + ttl_secs, + max_entries, + } + } + + /// 获取或创建租户的 DEK + pub fn get_or_create_dek( + &self, + tenant_id: Uuid, + encrypted_dek: Option<&str>, + kek: &[u8; 32], + ) -> AppResult<([u8; 32], u32)> { + // 检查缓存 + if let Some(entry) = self.cache.get(&tenant_id) { + if entry.loaded_at.elapsed().as_secs() < self.ttl_secs { + return Ok((entry.dek, entry.version)); + } + } + + // 从加密 DEK 解密 + if let Some(enc_dek) = encrypted_dek { + let dek_hex = engine::decrypt(kek, enc_dek).map_err(|e| AppError::Internal(e))?; + let dek_bytes = hex::decode(&dek_hex).map_err(|e| AppError::Internal(e.to_string()))?; + if dek_bytes.len() != 32 { + return Err(AppError::Internal("DEK must be 32 bytes".into())); + } + let mut dek = [0u8; 32]; + dek.copy_from_slice(&dek_bytes); + + // 缓存(版本从外部传入时无法确定,使用默认值 1) + self.evict_if_full(); + self.cache.insert(tenant_id, CachedDek { + dek, + version: 1, + loaded_at: Instant::now(), + }); + return Ok((dek, 1)); + } + + // 无现有 DEK → 生成新的 + let dek = Self::generate_dek(); + self.evict_if_full(); + self.cache.insert(tenant_id, CachedDek { + dek, + version: 1, + loaded_at: Instant::now(), + }); + Ok((dek, 1)) + } + + /// 使用 KEK 加密 DEK 以便存储 + pub fn encrypt_dek_for_storage(dek: &[u8; 32], kek: &[u8; 32]) -> AppResult { + let dek_hex = hex::encode(dek); + engine::encrypt(kek, &dek_hex).map_err(|e| AppError::Internal(e)) + } + + /// 生成新 DEK 并用 KEK 加密,返回 (新 DEK, 加密后的 DEK) + pub fn generate_new_dek(kek: &[u8; 32]) -> AppResult<([u8; 32], String)> { + let dek = Self::generate_dek(); + let encrypted = Self::encrypt_dek_for_storage(&dek, kek)?; + Ok((dek, encrypted)) + } + + /// 使缓存失效(轮换后调用) + pub fn invalidate(&self, tenant_id: Uuid) { + self.cache.remove(&tenant_id); + } + + fn generate_dek() -> [u8; 32] { + use rand::RngCore; + let mut dek = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut dek); + dek + } + + fn evict_if_full(&self) { + if self.cache.len() >= self.max_entries { + // 简单策略:移除最早加载的一半 + let to_remove: Vec = self.cache + .iter() + .filter(|e| e.loaded_at.elapsed().as_secs() > self.ttl_secs / 2) + .map(|e| *e.key()) + .take(self.max_entries / 2) + .collect(); + for id in to_remove { + self.cache.remove(&id); + } + } + } +} diff --git a/crates/erp-core/src/crypto/mod.rs b/crates/erp-core/src/crypto/mod.rs new file mode 100644 index 0000000..ce94f1e --- /dev/null +++ b/crates/erp-core/src/crypto/mod.rs @@ -0,0 +1,143 @@ +pub mod engine; +pub mod hmac_index; +pub mod key_manager; +pub mod masking; + +pub use engine::{decrypt, encrypt}; +pub use hmac_index::hmac_hash; +pub use masking::{mask_id_number, mask_phone}; +pub use key_manager::DekManager; + +use crate::error::{AppError, AppResult}; + +/// PII 加密服务 — 封装 KEK 和 DEK 管理 +#[derive(Clone)] +pub struct PiiCrypto { + kek: [u8; 32], + pub dek_manager: DekManager, +} + +impl PiiCrypto { + /// 从 hex 编码的 KEK 创建。KEK 为 64 字符 hex(32 字节)。 + pub fn from_kek_hex(kek_hex: &str) -> AppResult { + let bytes = + hex::decode(kek_hex).map_err(|e| AppError::Internal(format!("KEK hex decode failed: {}", e)))?; + if bytes.len() != 32 { + return Err(AppError::Internal("KEK must be 32 bytes (64 hex chars)".into())); + } + let mut kek = [0u8; 32]; + kek.copy_from_slice(&bytes); + Ok(Self { + kek, + dek_manager: DekManager::new(300, 100), // 5 min TTL, 100 entries + }) + } + + /// Dev fallback: 从固定字符串派生确定性 KEK。仅用于开发。 + pub fn dev_default() -> Self { + use sha2::Digest; + let kek = ::digest(b"erp-pii-kek-dev-key-DO-NOT-USE-IN-PROD"); + let mut key = [0u8; 32]; + key.copy_from_slice(&kek); + Self { + kek: key, + dek_manager: DekManager::new(300, 100), + } + } + + pub fn kek(&self) -> &[u8; 32] { + &self.kek + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_crypto() -> PiiCrypto { + PiiCrypto::dev_default() + } + + #[test] + fn from_kek_hex_roundtrip() { + let kek_hex = "00".repeat(32); + let crypto = PiiCrypto::from_kek_hex(&kek_hex).unwrap(); + assert_eq!(crypto.kek(), &[0u8; 32]); + } + + #[test] + fn from_kek_hex_invalid() { + assert!(PiiCrypto::from_kek_hex("not-hex").is_err()); + } + + #[test] + fn from_kek_hex_wrong_length() { + assert!(PiiCrypto::from_kek_hex("ab").is_err()); + } + + #[test] + fn encrypt_decrypt_roundtrip() { + let crypto = test_crypto(); + let plaintext = "13812345678"; + let encrypted = encrypt(crypto.kek(), plaintext).unwrap(); + let decrypted = decrypt(crypto.kek(), &encrypted).unwrap(); + assert_eq!(plaintext, decrypted); + } + + #[test] + fn encrypt_produces_different_ciphertexts() { + let crypto = test_crypto(); + let e1 = encrypt(crypto.kek(), "test").unwrap(); + let e2 = encrypt(crypto.kek(), "test").unwrap(); + assert_ne!(e1, e2); + } + + #[test] + fn decrypt_wrong_key_fails() { + let crypto1 = PiiCrypto::dev_default(); + let other_key_hex = "ff".repeat(32); + let crypto2 = PiiCrypto::from_kek_hex(&other_key_hex).unwrap(); + let encrypted = encrypt(crypto1.kek(), "test").unwrap(); + assert!(decrypt(crypto2.kek(), &encrypted).is_err()); + } + + #[test] + fn hmac_hash_deterministic() { + let crypto = test_crypto(); + let h1 = hmac_hash(crypto.kek(), "13812345678"); + let h2 = hmac_hash(crypto.kek(), "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"); + assert_ne!(h1, h2); + } + + #[test] + fn encrypt_empty_string() { + let crypto = test_crypto(); + let encrypted = encrypt(crypto.kek(), "").unwrap(); + let decrypted = decrypt(crypto.kek(), &encrypted).unwrap(); + assert_eq!("", decrypted); + } + + #[test] + fn decrypt_too_short_fails() { + use base64::Engine; + let short = base64::engine::general_purpose::STANDARD.encode(b"short"); + assert!(decrypt(&[0u8; 32], &short).is_err()); + } + + #[test] + fn encrypt_unicode() { + let crypto = test_crypto(); + let plaintext = "患者过敏史:青霉素、磺胺类药物"; + let encrypted = encrypt(crypto.kek(), plaintext).unwrap(); + let decrypted = decrypt(crypto.kek(), &encrypted).unwrap(); + assert_eq!(plaintext, decrypted); + } +} diff --git a/crates/erp-server/src/handlers/crypto_admin.rs b/crates/erp-server/src/handlers/crypto_admin.rs new file mode 100644 index 0000000..699dece --- /dev/null +++ b/crates/erp-server/src/handlers/crypto_admin.rs @@ -0,0 +1,47 @@ +use axum::extract::{FromRef, Path, State}; +use axum::Extension; +use axum::Json; +use serde_json::{json, Value}; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; + +use crate::state::AppState; + +/// POST /api/v1/admin/tenants/:id/rotate-key +/// 密钥轮换 — 生成新 DEK 并使缓存失效 +pub async fn rotate_tenant_key( + State(state): State, + Extension(ctx): Extension, + Path(tenant_id): Path, +) -> Result>, AppError> +where + AppState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "tenant.manage")?; + + let kek = state.pii_crypto.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 缓存失效 + state.pii_crypto.dek_manager.invalidate(tenant_id); + + tracing::info!( + tenant_id = %tenant_id, + new_version = new_version, + "密钥轮换已执行(DEK 缓存已清除)" + ); + + Ok(Json(ApiResponse::ok(json!({ + "message": "密钥轮换已启动", + "tenant_id": tenant_id, + "new_version": new_version, + "note": "后台重加密任务需要单独触发(当前为单 KEK 模式,轮换后新数据使用新 DEK)" + })))) +} diff --git a/crates/erp-server/src/handlers/mod.rs b/crates/erp-server/src/handlers/mod.rs index f7d7de1..8e8c090 100644 --- a/crates/erp-server/src/handlers/mod.rs +++ b/crates/erp-server/src/handlers/mod.rs @@ -1,3 +1,4 @@ pub mod audit_log; +pub mod crypto_admin; pub mod health; pub mod openapi; diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 97b3e15..6a04134 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -509,6 +509,10 @@ async fn main() -> anyhow::Result<()> { .merge(erp_health::HealthModule::protected_routes()) .merge(erp_ai::AiModule::protected_routes()) .merge(handlers::audit_log::audit_log_router()) + .route( + "/admin/tenants/{id}/rotate-key", + axum::routing::post(handlers::crypto_admin::rotate_tenant_key), + ) .layer(axum::middleware::from_fn_with_state( state.clone(), middleware::rate_limit::rate_limit_by_user,