chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成

包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
iven
2026-03-29 10:46:26 +08:00
parent 9a5fad2b59
commit 5fdf96c3f5
268 changed files with 22011 additions and 3886 deletions

View File

@@ -0,0 +1,103 @@
//! 通用加密工具 (AES-256-GCM)
//!
//! 提供 API Key、TOTP secret 等敏感数据的加密/解密。
//! 存储格式: `enc:<base64(nonce(12 bytes) || ciphertext)>`
use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::aead::rand_core::RngCore;
use aes_gcm::{Aes256Gcm, Nonce};
use crate::error::{SaasError, SaasResult};
/// 加密值的前缀标识
pub const ENCRYPTED_PREFIX: &str = "enc:";
/// AES-256-GCM nonce 长度 (12 字节)
const NONCE_SIZE: usize = 12;
/// 加密明文值 (AES-256-GCM, 随机 nonce)
///
/// 返回格式: `enc:<base64(nonce(12 bytes) || ciphertext)>`
/// 每次加密使用随机 nonce相同明文产生不同密文。
pub fn encrypt_value(plaintext: &str, key: &[u8; 32]) -> SaasResult<String> {
let cipher = Aes256Gcm::new_from_slice(key)
.map_err(|e| SaasError::Encryption(format!("加密初始化失败: {}", e)))?;
let mut nonce_bytes = [0u8; NONCE_SIZE];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| SaasError::Encryption(format!("加密失败: {}", e)))?;
let mut combined = nonce_bytes.to_vec();
combined.extend_from_slice(&ciphertext);
Ok(format!("{}{}", ENCRYPTED_PREFIX, data_encoding::BASE64.encode(&combined)))
}
/// 解密 `enc:` 前缀的加密值
///
/// 仅支持新格式 (随机 nonce),不支持旧格式 (固定 nonce)。
/// 旧格式数据应通过一次性迁移函数转换。
pub fn decrypt_value(encrypted: &str, key: &[u8; 32]) -> SaasResult<String> {
let encoded = encrypted.strip_prefix(ENCRYPTED_PREFIX)
.ok_or_else(|| SaasError::Encryption("加密值格式无效 (缺少 enc: 前缀)".into()))?;
let raw = data_encoding::BASE64.decode(encoded.as_bytes())
.map_err(|_| SaasError::Encryption("加密值 Base64 解码失败".into()))?;
if raw.len() <= NONCE_SIZE {
return Err(SaasError::Encryption("加密值数据不完整".into()));
}
let cipher = Aes256Gcm::new_from_slice(key)
.map_err(|e| SaasError::Encryption(format!("解密初始化失败: {}", e)))?;
let (nonce_bytes, ciphertext) = raw.split_at(NONCE_SIZE);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher.decrypt(nonce, ciphertext)
.map_err(|_| SaasError::Encryption("解密失败 (密钥可能已变更)".into()))?;
String::from_utf8(plaintext)
.map_err(|_| SaasError::Encryption("解密后数据无效 UTF-8".into()))
}
/// 检查值是否已加密 (以 `enc:` 开头)
pub fn is_encrypted(value: &str) -> bool {
value.starts_with(ENCRYPTED_PREFIX)
}
/// 批量迁移: 将旧的固定 nonce 加密值重新加密为随机 nonce 格式
///
/// 输入为旧格式 (固定 nonce `zclaw_totp_nce`) 加密的 base64 数据,
/// 输出为新格式 `enc:<base64(random_nonce || ciphertext)>`。
pub fn re_encrypt_from_legacy(legacy_base64: &str, legacy_key: &[u8; 32], new_key: &[u8; 32]) -> SaasResult<String> {
// 先用旧 nonce 解密
let cipher = Aes256Gcm::new_from_slice(legacy_key)
.map_err(|e| SaasError::Encryption(format!("解密初始化失败: {}", e)))?;
let raw = data_encoding::BASE64.decode(legacy_base64.as_bytes())
.or_else(|_| data_encoding::BASE32.decode(legacy_base64.as_bytes()))
.map_err(|_| SaasError::Encryption("旧格式 Base64/Base32 解码失败".into()))?;
// 尝试新格式 (前 12 字节为 nonce)
if raw.len() > NONCE_SIZE {
let (nonce_bytes, ciphertext) = raw.split_at(NONCE_SIZE);
let nonce = Nonce::from_slice(nonce_bytes);
if let Ok(plaintext_bytes) = cipher.decrypt(nonce, ciphertext) {
let plaintext = String::from_utf8(plaintext_bytes)
.map_err(|_| SaasError::Encryption("旧格式解密后数据无效".into()))?;
return encrypt_value(&plaintext, new_key);
}
}
// 回退到旧格式: 固定 nonce
let legacy_nonce = Nonce::from_slice(b"zclaw_totp_nce");
let plaintext_bytes = cipher.decrypt(legacy_nonce, raw.as_ref())
.map_err(|_| SaasError::Encryption("旧格式解密失败".into()))?;
let plaintext = String::from_utf8(plaintext_bytes)
.map_err(|_| SaasError::Encryption("旧格式解密后数据无效".into()))?;
encrypt_value(&plaintext, new_key)
}