feat(core): DEK 缓存 + 密钥轮换管理端点
- 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 (仅超级管理员)
This commit is contained in:
@@ -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"
|
||||
|
||||
120
crates/erp-core/src/crypto/key_manager.rs
Normal file
120
crates/erp-core/src/crypto/key_manager.rs
Normal file
@@ -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<Uuid, CachedDek>,
|
||||
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<String> {
|
||||
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<Uuid> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
143
crates/erp-core/src/crypto/mod.rs
Normal file
143
crates/erp-core/src/crypto/mod.rs
Normal file
@@ -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<Self> {
|
||||
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 = <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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
47
crates/erp-server/src/handlers/crypto_admin.rs
Normal file
47
crates/erp-server/src/handlers/crypto_admin.rs
Normal file
@@ -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<S>(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(tenant_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<Value>>, AppError>
|
||||
where
|
||||
AppState: FromRef<S>,
|
||||
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)"
|
||||
}))))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod audit_log;
|
||||
pub mod crypto_admin;
|
||||
pub mod health;
|
||||
pub mod openapi;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user